diff --git a/.claude/skills/review-issue/SKILL.md b/.claude/skills/review-issue/SKILL.md index 384aa137c..dc3ee110a 100644 --- a/.claude/skills/review-issue/SKILL.md +++ b/.claude/skills/review-issue/SKILL.md @@ -31,7 +31,7 @@ merge, not a courtesy. - Maintainer-authored PRs are exempt. A `trusted-contributor` label exempts a contributor up front. Reopening the PR or removing the `missing-issue-link` label applies a sticky `bypass-issue-check`. -- Sibling bots have usually already run on the issue: `marvin-triage-issue` (investigates + +- Sibling bots have usually already run on the issue: `martian-triage-issue` (investigates + recommends), `marvin-dedupe-issues` / `auto-close-duplicates` (dupes), `auto-close-needs-mre` (missing MRE). Read their comments before re-deriving anything. diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index ae37ee33a..93d0ebbf8 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -96,9 +96,12 @@ Codex sometimes re-posts old comments that reference code you've already fixed ( ## Labels — never apply or invent them -**Do not apply labels to PRs or issues programmatically, and never create new ones.** Issues and PRs in this repo are auto-labeled by a bot based on title, body, and code changes — there's no fixed canonical list to match against, and GitHub's "add labels" API auto-creates any label name that doesn't already exist, so a typo or guessed name silently pollutes the repo's label list with a stray, uncolored duplicate. There is no MCP tool to delete a label, so a mistaken creation can only be cleaned up by hand in repo settings. +**Do not apply labels to PRs or issues programmatically, and never create new ones.** Labeling is the maintainer's call (and is often automated). Two hard rules: -Don't call out a "suggested" or "appropriate" label in the PR body either — the bot doesn't read it, and it just adds noise. +- **Never invent a label.** GitHub's "add labels" API *auto-creates* any label name that doesn't already exist — so a typo or a guessed name silently pollutes the repo's label list with a stray, uncolored duplicate. Adding `breaking` (which does not exist) creates it alongside the real `breaking change` label. +- **Use only labels that already exist.** If you genuinely need to confirm a label, look it up first (`get_label` / the repo's label list) and match the exact name. The canonical names here are specific — e.g. the breaking-change label is **`breaking change`**, not `breaking`; enhancements is **`enhancements`**, features is **`features`**, bugs is **`bugs`**. + +When a change warrants a label (e.g. it's breaking), **say so in the PR body and let the maintainer apply the label** rather than applying it yourself. There is no MCP tool to delete a label, so a mistaken creation can only be cleaned up by hand in repo settings — the cost of guessing is high and one-directional. ## When a PR is ready diff --git a/.github/actions/run-claude/action.yml b/.github/actions/run-claude/action.yml index b79131462..fff6788a6 100644 --- a/.github/actions/run-claude/action.yml +++ b/.github/actions/run-claude/action.yml @@ -37,15 +37,10 @@ inputs: required: false default: "" - extra-allowed-tools: - description: "Additional comma-separated tools to append to allowed-tools" - required: false - default: "" - model: description: "Model to use for Claude" required: false - default: "claude-opus-4-8" + default: "claude-opus-4-6" allowed-bots: description: "Allowed bot usernames, or '*' for all bots" @@ -93,7 +88,7 @@ runs: track_progress: ${{ inputs.track-progress }} prompt: ${{ inputs.prompt }} claude_args: | - ${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools ''{0}{1}''', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }} + ${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools {0}{1}', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }} ${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }} --model ${{ inputs.model }} settings: | diff --git a/.github/actions/run-pytest/action.yml b/.github/actions/run-pytest/action.yml index c82e9c0bd..b7e5509e5 100644 --- a/.github/actions/run-pytest/action.yml +++ b/.github/actions/run-pytest/action.yml @@ -19,7 +19,7 @@ runs: MAX_PROCS="2" EXTRA_FLAGS="" elif [ "${{ inputs.test-type }}" == "client_process" ]; then - MARKER="client_process or subprocess_heavy" + MARKER="client_process" TIMEOUT="5" MAX_PROCS="0" EXTRA_FLAGS="-x" @@ -29,33 +29,17 @@ runs: MAX_PROCS="0" EXTRA_FLAGS="-x" else - MARKER="not integration and not client_process and not subprocess_heavy and not conformance" + MARKER="not integration and not client_process and not conformance" TIMEOUT="5" MAX_PROCS="4" EXTRA_FLAGS="" fi - # Windows previously ran serially: parallel workers crashed intermittently - # when many tests spawned stdio subprocesses (#2715, reverted in #2726). - # Most of those tests now run in-memory, but tests that spawn a fresh - # interpreter importing all of FastMCP still crash xdist workers on the - # 2-core Windows runners. They carry the subprocess_heavy marker and run - # in the serial client_process step instead. PARALLEL_FLAGS="" - if [ "$MAX_PROCS" != "0" ]; then + if [ "$MAX_PROCS" != "0" ] && [ "${{ runner.os }}" != "Windows" ]; then PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal" fi - # pytest-timeout has no signal-based method on Windows, so it falls back - # to the thread method, which dumps stacks and os._exit()s the process. - # Under a contended runner that turns a single slow test into a dead - # xdist worker, failing whichever unrelated test that worker happened to - # be running. Give parallel Windows runs more headroom so ordinary - # scheduling jitter does not take a worker down. - if [ "$RUNNER_OS" == "Windows" ] && [ "$MAX_PROCS" != "0" ]; then - TIMEOUT=$((TIMEOUT * 4)) - fi - uv run --no-sync pytest \ --inline-snapshot=disable \ --timeout=$TIMEOUT \ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..20d3ccecf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + labels: + - "dependencies" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/scripts/triage-label.sh b/.github/scripts/triage-label.sh index c6bdda85c..88081766f 100755 --- a/.github/scripts/triage-label.sh +++ b/.github/scripts/triage-label.sh @@ -48,23 +48,16 @@ done # Never let triage add or remove the Require Issue Link control labels. Those # govern PR enforcement (bypass-issue-check / trusted-contributor are sticky -# exemptions, "prs welcome" waives the assignment requirement) and reopening -# (missing-issue-link is how closed PRs are found), so a prompt-injected triage -# run must not be able to grant an exemption or break recovery. Enforced here — -# in code — not merely in the prompt. -# -# Exact match against array entries, not a substring scan of a joined string: -# label names may contain spaces ("prs welcome"), which in a space-delimited -# string would also make bare "prs" and "welcome" match. -protected=(missing-issue-link bypass-issue-check trusted-contributor "prs welcome") +# exemptions) and reopening (missing-issue-link is how closed PRs are found), +# so a prompt-injected triage run must not be able to grant an exemption or +# break recovery. Enforced here — in code — not merely in the prompt. +protected=" missing-issue-link bypass-issue-check trusted-contributor " for label in "$@"; do lower="${label,,}" - for p in "${protected[@]}"; do - if [[ "$lower" == "$p" ]]; then - echo "refusing to touch protected control label: $label" >&2 - exit 1 - fi - done + if [[ "$protected" == *" $lower "* ]]; then + echo "refusing to touch protected control label: $label" >&2 + exit 1 + fi done if [[ "$method" == POST ]]; then diff --git a/.github/workflows/marvin-test-failure.yml b/.github/workflows/martian-test-failure.yml similarity index 98% rename from .github/workflows/marvin-test-failure.yml rename to .github/workflows/martian-test-failure.yml index c0c532b23..c58978299 100644 --- a/.github/workflows/marvin-test-failure.yml +++ b/.github/workflows/martian-test-failure.yml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: true jobs: - marvin-test-failure: + martian-test-failure: # Only run if the test workflow failed if: ${{ github.event.workflow_run.conclusion == 'failure' }} runs-on: ubuntu-latest @@ -35,7 +35,7 @@ jobs: private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Set up Python 3.10 - uses: actions/setup-python@v7 + uses: actions/setup-python@v6 with: python-version: "3.10" @@ -193,5 +193,5 @@ jobs: prompt: ${{ steps.analysis-prompt.outputs.PROMPT }} claude_args: | - --allowed-tools mcp__repository-summary,mcp__code-search,mcp__github-research,WebSearch,WebFetch,"Bash(make:*)","Bash(git:*)" + --allowed-tools mcp__repository-summary,mcp__code-search,mcp__github-research,WebSearch,WebFetch,Bash(make:*,git:*) --mcp-config /tmp/mcp-config/mcp-servers.json diff --git a/.github/workflows/marvin-triage-issue.yml b/.github/workflows/martian-triage-issue.yml similarity index 100% rename from .github/workflows/marvin-triage-issue.yml rename to .github/workflows/martian-triage-issue.yml diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index a9de7d0d3..5815f98ed 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -19,13 +19,6 @@ jobs: issues: write id-token: write - # TEMPORARY PIN — see the matching note in marvin-label-triage.yml. - # Claude Code 2.1.216 broke every Bash call under the action's subprocess - # isolation, which this workflow needs for all of its `gh` searching. - # https://github.com/anthropics/claude-code/issues/79997 - env: - PINNED_CLAUDE_CODE_VERSION: "2.1.215" - steps: - name: Checkout repository uses: actions/checkout@v7 @@ -98,27 +91,19 @@ jobs: - name: Clean up stale Claude locks run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true - - name: Install pinned Claude Code - id: pin-claude - run: | - curl -fsSL https://claude.ai/install.sh | bash -s -- "$PINNED_CLAUDE_CODE_VERSION" - echo "path=$HOME/.local/bin/claude" >> "$GITHUB_OUTPUT" - "$HOME/.local/bin/claude" --version - - name: Run Marvin dedupe command uses: anthropics/claude-code-action@v1 with: - path_to_claude_code_executable: ${{ steps.pin-claude.outputs.path }} github_token: ${{ steps.marvin-token.outputs.token }} bot_name: "Marvin Context Protocol" prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} allowed_non_write_users: "*" claude_args: | - --allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh api:*)","Bash(gh issue comment:*)",Task + --allowedTools Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh api:*),Bash(gh issue comment:*),Task settings: | { - "model": "claude-sonnet-5", + "model": "claude-sonnet-4-6", "env": { "GH_TOKEN": "${{ steps.marvin-token.outputs.token }}" } diff --git a/.github/workflows/marvin-label-triage.yml b/.github/workflows/marvin-label-triage.yml index 2cd4fe7f5..933ff6528 100644 --- a/.github/workflows/marvin-label-triage.yml +++ b/.github/workflows/marvin-label-triage.yml @@ -27,22 +27,6 @@ jobs: issues: write pull-requests: write - # TEMPORARY PIN — remove once upstream ships a fix. - # - # Claude Code 2.1.216 regressed the sandbox that claude-code-action wraps - # every Bash call in when `allowed_non_write_users` is set: the mountpoint - # walk fails closed, so every command — down to `true` — dies with - # `bwrap: Can't create file at /home/.mcp.json: Permission denied`. - # Marvin still reads the issue and picks correct labels, then cannot run - # the helper that applies them, so triage silently applied zero labels - # from 2026-07-20 onward while every run reported success. - # - # 2.1.215 is the last release without the regression. - # https://github.com/anthropics/claude-code/issues/79997 - # https://github.com/anthropics/claude-code-action/issues/1547 - env: - PINNED_CLAUDE_CODE_VERSION: "2.1.215" - steps: - name: Checkout base repository uses: actions/checkout@v7 @@ -158,21 +142,9 @@ jobs: - name: Clean up stale Claude locks run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true - # Mirrors how the action installs Claude Code itself, minus the version - # it hardcodes. Passing path_to_claude_code_executable makes the action - # skip its own install and use this build. - - name: Install pinned Claude Code - id: pin-claude - run: | - curl -fsSL https://claude.ai/install.sh | bash -s -- "$PINNED_CLAUDE_CODE_VERSION" - echo "path=$HOME/.local/bin/claude" >> "$GITHUB_OUTPUT" - "$HOME/.local/bin/claude" --version - - name: Run Marvin for Issue Triage - id: marvin uses: anthropics/claude-code-action@v1 with: - path_to_claude_code_executable: ${{ steps.pin-claude.outputs.path }} github_token: ${{ steps.marvin-token.outputs.token }} bot_name: "Marvin Context Protocol" prompt: ${{ steps.triage-prompt.outputs.PROMPT }} @@ -180,114 +152,13 @@ jobs: allowed_non_write_users: "*" allowed_bots: "marvin-context-protocol" claude_args: | - --allowedTools "Bash(gh label list:*)","Bash(bash .github/scripts/triage-label.sh:*)",mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files + --allowedTools Bash(gh label list),Bash(bash .github/scripts/triage-label.sh:*),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request_files settings: | { - "model": "claude-sonnet-5", + "model": "claude-sonnet-4-6", "env": { "GH_TOKEN": "${{ steps.marvin-token.outputs.token }}", "TRIAGE_REPO": "${{ github.repository }}", "TRIAGE_NUMBER": "${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}" } } - - # Triage is fire-and-forget: nobody watches a green run, so a broken - # allowlist has to fail the job or it goes unnoticed indefinitely — a - # mangled pattern silently produced zero labels across a dozen PRs - # because the run still reported success. - # - # Only denials of commands we MEANT to grant indicate that breakage. An - # agent reaching for something never on the allowlist (falling back to - # `gh issue view` when the API is down, say) is behaving normally, and - # failing on that would cry wolf during every GitHub incident. - - name: Fail if Marvin could not run its tools - if: always() && steps.marvin.conclusion != 'skipped' - env: - EXECUTION_FILE: ${{ steps.marvin.outputs.execution_file }} - run: | - file="${EXECUTION_FILE:-}" - if [[ -z "$file" || ! -s "$file" ]]; then - file="${RUNNER_TEMP}/claude-execution-output.json" - fi - # A missing or empty log means we cannot tell a clean run from a - # blocked one, which is the exact failure this step exists to catch. - if [[ ! -s "$file" ]]; then - echo "::error::No Marvin execution log found; cannot verify tool permissions." - exit 1 - fi - - # The persisted log carries a `permission_denials` array on each - # `type: result` entry; the `permission_denials_count` scalar only - # appears in the action's condensed stdout summary, never on disk. - # Anchor to result entries rather than recursing with `..`, which - # descends into each denial's `tool_input` and double-counts any - # denied command that happens to mention the field name. - if ! summary=$(jq -sr ' - [ .[] | if type == "array" then .[] else . end ] - | map(select(type == "object" and .type == "result")) - | map(.permission_denials // []) | flatten - | map(.tool_input.command // "") - | { total: length, - granted: map(select( - startswith("gh label list") - or startswith("bash .github/scripts/triage-label.sh") - )) - } - | "\(.total)\t\(.granted | length)\t\(.granted | join(" | "))" - ' "$file"); then - echo "::error::Could not parse Marvin execution log ($file)." - exit 1 - fi - IFS=$'\t' read -r total granted commands <<<"$summary" - echo "Denied tool calls: $total (of which allowlisted: $granted)" - - if [[ "$granted" -gt 0 ]]; then - echo "::error::Marvin was denied $granted call(s) to tools this workflow grants, so it could not apply labels: ${commands}. The --allowedTools value is not reaching the permission matcher intact — claude_args is lexed with shell-quote, so any Bash(...) pattern containing a space must be quoted or it is split into fragments." - exit 1 - fi - if [[ "$total" -gt 0 ]]; then - echo "::notice::Marvin was denied $total call(s), none of them to tools this workflow grants. That is expected when it probes for a tool we deliberately withhold; the allowlist is intact." - fi - - # A granted tool can also fail *after* the permission check, which the - # denial count above cannot see. Claude Code 2.1.216 did exactly that: - # the sandbox refused to build and every Bash call — including the - # labeling helper — exited 1 with `bwrap: ...`, while the run stayed - # green. Correlate results back to their Bash tool_use rather than - # grepping the whole log, so an issue body quoting a sandbox error - # cannot fail an otherwise healthy run. - if ! sandbox=$(jq -sr ' - [ .[] | if type == "array" then .[] else . end ] - | map(select(type == "object" and (.type == "assistant" or .type == "user"))) - | map(.message.content // []) | flatten - | map(select(type == "object")) - | . as $blocks - | ( $blocks - | map(select(.type == "tool_use" and .name == "Bash")) - | map(.id) ) as $bash - | $blocks - | map(select(.type == "tool_result" and (.tool_use_id as $i | $bash | index($i)))) - | map(.content | tostring) - | map(select(test("bwrap:|Failed to (start|create) sandbox"))) - | "\(length)\t\(.[0] // "" | gsub("[\t\n]"; " ") | .[0:200])" - ' "$file"); then - echo "::error::Could not scan Marvin execution log for sandbox failures ($file)." - exit 1 - fi - IFS=$'\t' read -r sandbox_failures sandbox_sample <<<"$sandbox" - - if [[ "$sandbox_failures" -gt 0 ]]; then - echo "::error::Marvin's Bash tool failed $sandbox_failures time(s) inside the action's subprocess sandbox, so it could not apply labels: ${sandbox_sample}. This is an environment failure, not a prompt or allowlist problem — check whether the pinned Claude Code version (${PINNED_CLAUDE_CODE_VERSION}) still avoids the upstream sandbox regression." - exit 1 - fi - - - name: Upload Marvin execution log - if: always() && steps.marvin.conclusion != 'skipped' - uses: actions/upload-artifact@v7 - with: - name: marvin-triage-execution-log - path: | - ${{ steps.marvin.outputs.execution_file }} - ${{ runner.temp }}/claude-execution-output.json - if-no-files-found: ignore - retention-days: 14 diff --git a/.github/workflows/publish-fastmcp-tasks.yml b/.github/workflows/publish-fastmcp-tasks.yml deleted file mode 100644 index 29fc38554..000000000 --- a/.github/workflows/publish-fastmcp-tasks.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Publish fastmcp-tasks to PyPI - -on: - workflow_run: - workflows: ["Publish fastmcp-slim to PyPI"] - types: [completed] - workflow_dispatch: - -permissions: - contents: read - id-token: write - -jobs: - pypi-publish: - name: Upload fastmcp-tasks to PyPI - runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release') - - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 - ref: ${{ github.event.workflow_run.head_sha || github.sha }} - - # Maintenance branches predate the standalone fastmcp-tasks package and - # resolve the `tasks` extra through fastmcp-slim instead. This workflow - # runs from the default branch for every fastmcp-slim release, including - # those tags, so detect the package rather than assume it is there. - - name: Check whether this ref builds fastmcp-tasks - id: package_present - run: | - if [ -d fastmcp_tasks ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "This ref has no fastmcp_tasks package; nothing to publish." - fi - - - name: Install uv - uses: astral-sh/setup-uv@v7 - - - name: Build fastmcp-tasks - if: steps.package_present.outputs.present == 'true' - run: uv build --package fastmcp-tasks - - - name: Verify matching fastmcp-slim is published - if: steps.package_present.outputs.present == 'true' - run: | - SLIM_VERSION=$(python - <<'PY' - import email.parser - import re - import zipfile - from pathlib import Path - - wheel = next(Path("dist").glob("fastmcp_tasks-*.whl")) - metadata_name = next( - name for name in zipfile.ZipFile(wheel).namelist() - if name.endswith(".dist-info/METADATA") - ) - metadata = email.parser.Parser().parsestr( - zipfile.ZipFile(wheel).read(metadata_name).decode() - ) - for value in metadata.get_all("Requires-Dist", []): - requirement, _, marker = value.partition(";") - if marker.strip(): - continue - match = re.fullmatch( - r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)", - requirement.strip(), - ) - if match: - print(match.group(1)) - break - else: - raise RuntimeError("Could not find the base fastmcp-slim dependency") - PY - ) - - for attempt in {1..12}; do - if python - "$SLIM_VERSION" <<'PY' - import json - import sys - import urllib.request - - version = sys.argv[1] - url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json" - with urllib.request.urlopen(url, timeout=30) as response: - json.load(response) - PY - then - exit 0 - fi - - echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)." - sleep 10 - done - - echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp-tasks." >&2 - exit 1 - - - name: Publish fastmcp-tasks to PyPI - if: steps.package_present.outputs.present == 'true' - run: uv publish -v dist/fastmcp_tasks-*.tar.gz dist/fastmcp_tasks-*.whl diff --git a/.github/workflows/publish-fastmcp.yml b/.github/workflows/publish-fastmcp.yml index 8b2ce33b2..3e22fe915 100644 --- a/.github/workflows/publish-fastmcp.yml +++ b/.github/workflows/publish-fastmcp.yml @@ -115,90 +115,23 @@ jobs: echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp." >&2 exit 1 - - name: Verify matching fastmcp-tasks is published - run: | - TASKS_VERSION=$(python - <<'PY' - import email.parser - import re - import zipfile - from pathlib import Path - - wheel = next(Path("dist").glob("fastmcp-*.whl")) - metadata_name = next( - name for name in zipfile.ZipFile(wheel).namelist() - if name.endswith(".dist-info/METADATA") - ) - metadata = email.parser.Parser().parsestr( - zipfile.ZipFile(wheel).read(metadata_name).decode() - ) - # fastmcp-tasks is pinned via the optional `tasks` extra, so its - # Requires-Dist entry carries an `extra == "tasks"` marker — unlike the - # base slim dependency, do not skip marked entries here. - # - # Print nothing when there is no such pin. Release lines that resolve - # the `tasks` extra through fastmcp-slim instead of a standalone - # fastmcp-tasks package have nothing here to verify. - for value in metadata.get_all("Requires-Dist", []): - requirement, _, _marker = value.partition(";") - match = re.fullmatch(r"fastmcp-tasks==([^;\s]+)", requirement.strip()) - if match: - print(match.group(1)) - break - PY - ) - - if [ -z "$TASKS_VERSION" ]; then - echo "This build does not pin fastmcp-tasks; the [tasks] extra cannot be uninstallable, so there is nothing to verify." - exit 0 - fi - - for attempt in {1..12}; do - if python - "$TASKS_VERSION" <<'PY' - import json - import sys - import urllib.request - - version = sys.argv[1] - url = f"https://pypi.org/pypi/fastmcp-tasks/{version}/json" - with urllib.request.urlopen(url, timeout=30) as response: - json.load(response) - PY - then - exit 0 - fi - - echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI yet; retrying (${attempt}/12)." - sleep 10 - done - - echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI; refusing to publish fastmcp (the [tasks] extra would be uninstallable)." >&2 - exit 1 - - name: Publish fastmcp to PyPI run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl update-published-docs: - name: Open published-docs PR + name: Update published-docs branch runs-on: ubuntu-latest needs: pypi-publish if: github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release' && needs['pypi-publish'].outputs.is_prerelease != 'true' - timeout-minutes: 5 + timeout-minutes: 2 permissions: - contents: read + contents: write steps: - - name: Generate Marvin App token - id: marvin-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.MARVIN_APP_ID }} - private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - - uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ github.event.workflow_run.head_sha }} - token: ${{ steps.marvin-token.outputs.token }} - name: Check release line id: release_line @@ -213,26 +146,6 @@ jobs: echo "Release commit is not on ${DEFAULT_BRANCH}; skipping published-docs update." fi - - name: Prepare published docs tree + - name: Point published-docs at published release if: steps.release_line.outputs.update_published_docs == 'true' - env: - RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - git fetch origin published-docs - git switch --force-create published-docs-sync origin/published-docs - git read-tree --reset -u "$RELEASE_SHA" - test "$(git write-tree)" = "$(git rev-parse "${RELEASE_SHA}^{tree}")" - - - name: Open published docs PR - if: steps.release_line.outputs.update_published_docs == 'true' - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ steps.marvin-token.outputs.token }} - base: published-docs - branch: marvin/publish-docs-v${{ needs.pypi-publish.outputs.version }} - commit-message: "Publish FastMCP v${{ needs.pypi-publish.outputs.version }} docs" - title: "Publish FastMCP v${{ needs.pypi-publish.outputs.version }} docs" - body: "Updates `published-docs` to the exact release tree. Merging publishes the documentation to production." - delete-branch: true - author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" + run: git push --force origin "HEAD:published-docs" diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index 6d674d0e2..1f5fa2ea0 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -1,8 +1,5 @@ # Require external PRs to reference an issue with an auto-close keyword -# (e.g. "Fixes #123") AND have the PR author assigned to that issue — -# unless the referenced issue is labeled "prs welcome", which waives the -# assignment requirement for everyone (the link itself is still required, -# since that's how the check finds the issue to read the label from). +# (e.g. "Fixes #123") AND have the PR author assigned to that issue. # Otherwise the PR is labeled "missing-issue-link", commented on, and # closed. CONTRIBUTING.md requires external contributors to be assigned to # an issue before opening a PR; this enforces that. @@ -99,8 +96,6 @@ jobs: const enforce = process.env.ENFORCE_ISSUE_LINK === 'true'; const LABEL = 'missing-issue-link'; const MARKER = ''; - // Issue-level label that waives the assignment requirement. - const OPEN_LABEL = 'prs welcome'; // Dry-run guard: every mutating call goes through this so that // ENFORCE_ISSUE_LINK=false means strictly read-only. @@ -305,13 +300,6 @@ jobs: // CONTRIBUTING.md requires external contributors to be assigned // before opening a PR (so maintainers can deconflict / steer // approach first). - // - // Exception: an issue labeled OPEN_LABEL waives that requirement - // for everyone. It's how maintainers advertise "the reporter - // isn't implementing this, we'd take a PR from anyone" without - // having to assign a specific person up front. Unlike the - // PR-level `trusted-contributor` / `bypass-issue-check` escapes, - // this one lives on the *issue* and is set ahead of time. const MAX_ISSUES = 5; const allNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))]; const numbers = allNumbers.slice(0, MAX_ISSUES); @@ -338,19 +326,6 @@ jobs: throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? 'unknown'}): ${e.message}`); } sawRealIssue = true; - - // GitHub returns labels as objects here, but the REST schema - // permits bare strings — normalize both rather than assume. - const labelNames = (issue.labels || []) - .map(l => (typeof l === 'string' ? l : l && l.name)) - .filter(Boolean) - .map(n => n.toLowerCase()); - if (labelNames.includes(OPEN_LABEL)) { - console.log(`#${num} is labeled "${OPEN_LABEL}" — assignment not required`); - assignedToAny = true; - break; - } - const assignees = (issue.assignees || []).map(a => a.login.toLowerCase()); if (assignees.includes(prAuthor)) { console.log(`PR author ${pr.user.login} is assigned to #${num}`); diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml index 8297ef413..66ddb5c1b 100644 --- a/.github/workflows/run-static.yml +++ b/.github/workflows/run-static.yml @@ -10,7 +10,6 @@ on: - "fastmcp_slim/**" - "fastmcp_remote/**" - "tests/**" - - "examples/**" - "pyproject.toml" - "uv.lock" - ".github/workflows/**" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7db2b5865..aa5777786 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -48,7 +48,7 @@ jobs: - name: Run unit tests uses: ./.github/actions/run-pytest - - name: Run serial subprocess tests + - name: Run client process tests uses: ./.github/actions/run-pytest with: test-type: client_process @@ -69,7 +69,7 @@ jobs: - name: Run unit tests uses: ./.github/actions/run-pytest - - name: Run serial subprocess tests + - name: Run client process tests uses: ./.github/actions/run-pytest with: test-type: client_process @@ -88,7 +88,7 @@ jobs: resolution: locked - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v6 with: node-version: "22" diff --git a/.github/workflows/run-upgrade-checks.yml b/.github/workflows/run-upgrade-checks.yml index 485cf1919..3be7b8182 100644 --- a/.github/workflows/run-upgrade-checks.yml +++ b/.github/workflows/run-upgrade-checks.yml @@ -67,7 +67,7 @@ jobs: - name: Run unit tests uses: ./.github/actions/run-pytest - - name: Run serial subprocess tests + - name: Run client process tests uses: ./.github/actions/run-pytest with: test-type: client_process diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8321b5bde..304c3e463 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: entry: uv run --isolated ty check language: system types: [python] - files: ^fastmcp_slim/|^tests/|^examples/ + files: ^fastmcp_slim/|^tests/ pass_filenames: false require_serial: true diff --git a/CLAUDE.md b/CLAUDE.md index 79b040531..e900cc9c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,13 +56,11 @@ When modifying MCP functionality, changes typically need to be applied across al **Read `CONTRIBUTING.md` before opening issues or PRs.** It describes when PRs are appropriate, what we expect from enhancement proposals, and what we'll close without review. -**Review closed contributor PRs.** When reviewing an issue, inspect every associated non-maintainer PR, including closed PRs. External PRs may be closed as part of the issue-link and assignment workflow, so closure alone is not a negative signal. Read `CONTRIBUTING.md` and the PR timeline and comments to understand its status before evaluating it. - ### Git & CI - Prek hooks are required (run automatically on commits) - Never amend commits to fix prek failures -- Never apply labels manually or invent new ones — issues and PRs are auto-labeled by a bot based on title/body/code changes. Don't note a "suggested" or "appropriate" label anywhere in the PR body either. See the review-pr skill. +- Never apply labels manually or invent new ones — the GitHub API auto-creates any unknown label name, polluting the repo's label list. Note the appropriate label in the PR body and let the maintainer/automation apply it. Canonical names: `bugs`, `breaking change`, `enhancements`, `features` (it's `breaking change`, not `breaking`). See the review-pr skill. - Improvements = enhancements (not features) unless specified - **NEVER** force-push on collaborative repos - **ALWAYS** run prek before PRs @@ -70,12 +68,6 @@ When modifying MCP functionality, changes typically need to be applied across al - **NEVER** merge a PR marked as do-not-merge or draft. Check title, body, AND labels for `[DNM]`, `DNM`, `DO NOT MERGE`, `DON'T MERGE`, `DONT MERGE`, `do-not-merge`, `dont-merge`, `[DRAFT]`, or `DRAFT` (case-insensitive, any variation — some authors use `[DRAFT]` in the title even when `isDraft` is false). Authors use these as hard stops — respect them even if CI is green and review looks clean. When triaging a batch of PRs, filter these out up front AND re-check each one's labels immediately before merging, since labels can change mid-session. - **ALWAYS** read review-bot comments before approving a PR. CodeRabbit and chatgpt-codex-connector (Codex) leave substantive review comments on most PRs in this repo — these bots have read the diff and often flag real issues that aren't in the PR description. Use `gh pr view --comments` and read the bot feedback as part of review. Unlike proposed solutions from issue reporters, review-bot feedback should be evaluated on its merits, not discounted. - **Be constructively skeptical of bot review comments on your own PRs.** CodeRabbit, Codex, and claude[bot] run a fresh review pass on every push, which means a PR with active churn can accumulate bot comments in a stream that never really ends — each fix surfaces a new edge case the next pass can flag. Most of the early feedback is real and worth acting on; diminishing returns set in fast. Evaluate each comment on its merits, the same way you would a human reviewer: is this a real bug users will hit, or a hypothetical that requires an adversarial setup? Does the fix introduce more complexity than the problem? Has the bot missed context that's obvious to a human reader (a `*,` keyword-only marker, a design decision documented elsewhere, something already resolved on a later commit)? When a comment is pedantic, a false positive, or flagging something already fixed, reply on the thread explaining the reasoning and move on — don't keep iterating just because more comments arrive. If you find yourself three rounds deep and the feedback is shifting toward "what if someone does X" hypotheticals, you're past the point where each fix is improving the PR. Stop, document the contract as-is, and ship. -- **Resolve a review thread when you fix it; reply when you're declining it.** A fix explains itself through the commit, so resolving is enough — and it leaves unresolved threads meaning unfinished business, which is the signal worth having. A decline needs a one-line reason in a reply, because resolving collapses the thread and a hidden objection is worse than a visible one. Doing both is noise. Get thread ids from the GraphQL `reviewThreads` field, then resolve: - - ```bash - gh api graphql -f query='query($n:Int!){repository(owner:"PrefectHQ",name:"fastmcp"){pullRequest(number:$n){reviewThreads(first:50){nodes{id isResolved path}}}}}' -F n= - gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -F id=PRRT_... - ``` ### Outbound Comments and Shell Interpolation @@ -119,9 +111,7 @@ Set `target_commitish` to the same branch that will receive the release tag. For **Patch releases** (3.1.1, 3.0.2) get 1-2 sentences explaining what broke and what the fix does. Keep it minimal — the auto-generated changelog has the details. -**Publish docs through a PR.** The `published-docs` branch serves gofastmcp.com, and repository rules reject direct pushes and force-pushes to it. Stable releases from `main` automatically open a publication PR after PyPI succeeds. For prereleases and later docs follow-ups, create the same PR manually: start a temporary branch from the current `published-docs`, make a single commit whose tree exactly matches the desired commit on `main`, and use `published-docs` as the PR base. Merging publishes to production. Never push directly to `published-docs`. - -**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job opens a PR that syncs `published-docs` to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's publication PR will not include the changelog; publish `main` manually through the PR flow above or wait for the next default-branch stable release. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` — match the existing entries' style by hand): +**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job force-pushes the `published-docs` branch (which gofastmcp.com serves) to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's changelog won't appear on the live site until the next default-branch stable release force-pushes `published-docs` forward. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` — match the existing entries' style by hand): - `docs/changelog.mdx` is the full mirror. Add an `` block with: a bold linked title (`**[v: ]()**`), a condensed 1-paragraph intro (one sentence for patches), the full categorized PR list reformatted from the `--generate-notes` output (`* by [@user](https://github.com/user) in [#NNNN](<pull-url>)`), a `## New Contributors` list (plain `@user`, linked PR), and a `**Full Changelog**: [vA...vB](<compare-url>)` line. - `docs/updates.mdx` is the skimmable card feed. Add an `<Update label="FastMCP <version>" description="Month DD, YYYY" tags={["Releases"]}>` wrapping a `<Card>` that links to the GitHub release, with a 1-2 sentence summary and (for point releases) a handful of emoji-bulleted highlights. @@ -188,20 +178,6 @@ Because the docs land *before* the tag exists, derive the entry from the maintai - **Style:** Prose over code comments for important information - **Docstrings:** FastMCP docstrings are automatically compiled into MDX documents. Use markdown (single backticks, fenced code blocks), not RST (no double backticks). Bare `{}` in examples will be interpreted as JSX — wrap in backticks instead. -## Code Review Rules - -### Framework regressions and root causes - -- Review changes carefully for regressions in supported framework behavior, including interactions beyond the immediate diff. Trace relevant callers, shared abstractions, protocol and public API contracts, and all affected MCP component types. Determine whether a change fixes the causal code path or merely compensates for the symptom; side channels and special cases that leave the root cause intact should be treated as suspect. - -### Comprehensive first pass - -- Review the entire pull request diff against the merge base, not only the latest commits. Inspect every changed file and the relevant surrounding code, collect all independent, substantiated consequential findings before submitting the review, and report the complete set in one review whenever possible. Do not stop after finding the first few issues or defer other already-visible findings to later review cycles. - -### Prior discussion and proportionality - -- When prior review threads and author or maintainer replies are available, read them before commenting. Evaluate responses on their merits and do not repeat a resolved or convincingly rebutted finding without new evidence. Avoid fixating on speculative edge cases: report an edge case only when it is reachable under supported usage or a credible threat model and has meaningful impact; otherwise omit it or clearly treat it as non-blocking. - ## Critical Patterns - Never use bare `except` - be specific with exception types diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 745bab294..d91542643 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,6 @@ FastMCP is an actively maintained, high-traffic project. We welcome contributions — but the most impactful way to contribute might not be what you expect. -Participation is governed by our [Code of Conduct](CODE_OF_CONDUCT.md), and contributions are licensed under [Apache 2.0](LICENSE). - ## The best contribution is a great issue FastMCP is an opinionated framework, and its maintainers use AI-assisted tooling that is deeply tuned to those opinions — the design philosophy, the API patterns, the way the framework is meant to evolve. A well-written issue with a clear problem description is often more valuable than a pull request, because it lets maintainers produce a solution that isn't just correct, but consistent with how the framework wants to work. That matters more than speed, though it's faster too. @@ -28,10 +26,6 @@ An open issue is not an invitation to submit a PR, and it is not a queue you joi **Don't post drive-by comments claiming an issue** — "can I work on this?", "please assign me", "I'll take this." They don't affect who gets assigned, they're the most common form of noise we get, and automated versions are ignored. Whoever opens the issue has first claim on it; if that's you, a maintainer will assign you. If you want to implement something someone else reported, just open a PR — you don't need permission to try, and competing PRs are fine — but it's reviewed only if a maintainer assigns you to the issue, which usually won't happen if the reporter intends to handle it. The one comment worth posting is a genuinely different approach worth discussing; a substantive design proposal is welcome, a bare claim on the task is not. -**Issues labeled `prs welcome` skip the assignment gate.** When we apply that label, we're saying the reporter isn't implementing it and we'd take a PR from anyone. Open one directly — no assignment needed, and it won't be auto-closed. Still reference the issue (`Fixes #123`), since that's how the check knows which issue to look at. - -**What assignment means.** Being assigned is a commitment on both sides: we'll review your work seriously, and you'll see it through. That means responding to review feedback yourself and being able to explain any part of your change and why you made it that way. Use whatever tooling you like to get there — but if you can't answer a question about your own diff, we'll unassign the issue so someone else can pick it up. - **Bug fixes** — PRs are welcome for simple, well-scoped bug fixes where the problem and solution are both straightforward. "The function raises `TypeError` when passed `None` because of a missing guard" is a good candidate. If the fix requires design decisions or touches multiple subsystems, open an issue with a design proposal instead. **Documentation** — Typo fixes, clarifications, and improvements to examples are always welcome as PRs. @@ -44,9 +38,7 @@ An open issue is not an invitation to submit a PR, and it is not a queue you joi If you do open a PR: -- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you — unless it's labeled `prs welcome`, which waives the assignment requirement. If there isn't an issue, open one. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet these conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned. -- **Leave "Allow edits by maintainers" enabled.** We frequently take a PR the last few steps ourselves rather than block on another round trip — tightening a test, adjusting naming, rebasing. It's enabled by default on PRs from personal forks; leave it that way. GitHub doesn't allow it at all for forks owned by an organization, so if you're contributing from one, expect us to land the final changes separately. -- **Target the right branch.** Open against `main` unless you're fixing something specific to a maintenance line, in which case target that branch directly (`release/3.x`, `release/2.x`). +- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you. If there isn't an issue, open one. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet both conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned. - **If your PR was auto-closed, don't open a new one.** Edit the *existing* PR to add the issue link, get assigned to that issue, and it reopens on its own — the branch and history are preserved. A duplicate PR just starts you over and adds to the triage pile. - **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors. - **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting. diff --git a/README.md b/README.md index 920996af9..b2b8c1886 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ [![Docs](https://img.shields.io/badge/docs-gofastmcp.com-blue)](https://gofastmcp.com) [![Discord](https://img.shields.io/badge/community-discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/uu8dJCgttd) [![PyPI - Version](https://img.shields.io/pypi/v/fastmcp.svg)](https://pypi.org/project/fastmcp) -[![TypeScript](https://img.shields.io/npm/v/%40prefecthq%2Ffastmcp-ts?label=typescript&color=3178c6)](https://github.com/PrefectHQ/fastmcp-ts) [![Tests](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml/badge.svg)](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml) [![License](https://img.shields.io/github/license/PrefectHQ/fastmcp.svg)](https://github.com/PrefectHQ/fastmcp/blob/main/LICENSE) @@ -26,7 +25,7 @@ --- -The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP is a full MCP application framework for servers, clients, and interactive apps. A server starts with ordinary Python: +The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production: ```python from fastmcp import FastMCP @@ -78,15 +77,13 @@ FastMCP has three pillars: **[Servers](https://gofastmcp.com/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](https://gofastmcp.com/clients/client)** connect to any server with full protocol support. And **[Apps](https://gofastmcp.com/apps/overview)** give your tools interactive UIs rendered directly in the conversation. -**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. Same pillars, same ideas, `npm install @prefecthq/fastmcp-ts`. - Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart). -## Scale MCP with Horizon +## Run FastMCP in production with Horizon -FastMCP handles the MCP application layer. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_body)** is the enterprise MCP gateway for scaling servers and tools across teams, with centralized governance over how they are deployed, discovered, secured, and used. +FastMCP is the standard way to build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_body)** is the enterprise MCP gateway for running them safely. -FastMCP and Horizon are built by the same team at [Prefect](https://www.prefect.io/). +Built by the FastMCP team, Horizon packages the best practices we've learned shipping the world's most popular MCP framework. Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents. @@ -94,19 +91,21 @@ Start with FastMCP. [Scale with Horizon →](https://www.prefect.io/horizon?utm_ ## Installation -We recommend adding FastMCP to your project with [uv](https://docs.astral.sh/uv/): +We recommend installing FastMCP with [uv](https://docs.astral.sh/uv/): ```bash -uv add fastmcp +uv pip install fastmcp ``` For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation). **Upgrading?** We have guides for: -- [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3) -- [Upgrading from FastMCP 2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) -- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2) -- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2) +- [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) +- [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk) +- [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk) + +> [!NOTE] +> If `import fastmcp` fails right after a `pip` upgrade from FastMCP 3.2 or earlier, run `pip install --force-reinstall fastmcp`. See [Troubleshooting](https://gofastmcp.com/getting-started/installation#troubleshooting) for why this happens (`uv` is unaffected). ## 📚 Documentation diff --git a/dev-docs/v3-notes/auth-provider-env-vars.md b/dev-docs/v3-notes/auth-provider-env-vars.md deleted file mode 100644 index c61f61cbe..000000000 --- a/dev-docs/v3-notes/auth-provider-env-vars.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Auth Provider Environment Variables ---- - -## Decision: Remove automatic environment variable loading from auth providers - -You can still use environment variables for configuration - you just read them yourself with `os.environ` instead of relying on FastMCP's automatic loading. - -**Status:** Implemented in v3.0.0 - -### Background - -Auth providers in v2.x used `pydantic-settings` to automatically load configuration from environment variables with a `FASTMCP_SERVER_AUTH_<PROVIDER>_` prefix. For example, `GitHubProvider` would read from: - -- `FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID` -- `FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET` -- `FASTMCP_SERVER_AUTH_GITHUB_BASE_URL` -- etc. - -This was implemented via a `*ProviderSettings(BaseSettings)` class in each provider, combined with a `NotSet` sentinel pattern to distinguish between "not provided" and `None`. - -### Why remove it - -1. **Maintenance burden**: Every new provider needed to implement the settings class, validators, and the `NotSet` merging logic. This was ~50-100 lines of boilerplate per provider. - -2. **Documentation complexity**: Each provider needed documentation explaining both the parameter and the corresponding environment variable. This doubled the surface area to document and maintain. - -3. **Contributor friction**: New contributors adding providers had to understand and replicate this pattern, which was a source of inconsistency and bugs. - -4. **Marginal user value**: Python developers are comfortable with `os.environ["VAR"]` or `os.environ.get("VAR", default)`. The automatic loading saved a single line of code per parameter while adding significant complexity. - -5. **Implicit behavior**: Magic environment variable loading makes it harder to understand where values come from. Explicit `os.environ` calls are more traceable. - -### Migration path - -The migration is trivial - users add explicit environment variable reads: - -```python -# Before (v2.x) -auth = GitHubProvider() # Relied on env vars - -# After (v3.0) -import os - -auth = GitHubProvider( - client_id=os.environ["GITHUB_CLIENT_ID"], - client_secret=os.environ["GITHUB_CLIENT_SECRET"], - base_url=os.environ["MY_BASE_URL"], -) -``` - -Users can also use `os.environ.get()` with defaults, or any other configuration library they prefer (dotenv, dynaconf, etc.). - -### Backwards compatibility - -We chose not to provide backwards compatibility because: - -1. This is a major version bump (v3.0), which is the appropriate time for breaking changes -2. The migration is straightforward (add `os.environ` calls) -3. Maintaining compatibility would require keeping all the boilerplate we're trying to remove -4. The pattern was likely not heavily used - most production deployments pass secrets explicitly rather than relying on magic prefixes - -### What was removed - -- `*ProviderSettings(BaseSettings)` classes from all auth providers -- `NotSet` sentinel usage in provider constructors -- `pydantic-settings` dependency for auth providers -- Environment variable documentation from provider docs -- Related test cases for env var loading - -### Result - -Provider constructors are now simple and explicit. Required parameters are actually required (Python raises `TypeError` if missing), and optional parameters have clear defaults. The code is more readable and easier to maintain. diff --git a/dev-docs/v3-notes/v3-features.md b/dev-docs/v3-notes/v3-features.md deleted file mode 100644 index 62d11e0b1..000000000 --- a/dev-docs/v3-notes/v3-features.md +++ /dev/null @@ -1,1481 +0,0 @@ ---- -title: v3.0 Feature Tracking ---- - -This document tracks major features in FastMCP v3.0 for release notes preparation. - -## 3.0.0rc1 - -### SamplingTool Conversion Helpers - -Server tools (FunctionTool and TransformedTool) can now be passed directly to sampling methods via `SamplingTool.from_callable_tool()` ([#3062](https://github.com/PrefectHQ/fastmcp/pull/3062)). Previously, tools defined with `@mcp.tool` had to be recreated as functions for use in `ctx.sample()`. Now `ctx.sample()` and `ctx.sample_step()` accept these tool instances directly. - -```python -@mcp.tool -def search(query: str) -> str: - """Search the web.""" - return do_search(query) - -# Use tool directly in sampling -result = await ctx.sample( - "Research Python frameworks", - tools=[search] # FunctionTool works directly! -) -``` - -### Google GenAI Sampling Handler - -FastMCP now includes a sampling handler for Google's Gemini models ([#2977](https://github.com/jlowin/fastmcp/pull/2977)). This enables MCP clients to use Google's GenAI models with the sampling protocol, including full tool calling support. - -```python -from fastmcp import Client -from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHandler -from google.genai import Client as GoogleGenaiClient - -# Initialize the handler -handler = GoogleGenaiSamplingHandler( - default_model="gemini-2.0-flash-exp", - client=GoogleGenaiClient(), # Optional - creates one if not provided -) - -# Use with MCP sampling (handler is configured at Client construction) -async with Client("http://server/mcp", sampling_handler=handler) as client: - result = await client.sample( - messages=[...], - tools=[...], - ) -``` - -Key features: -- Converts MCP tool schemas to Google's function calling format -- Supports all Google GenAI models that implement function calling -- Handles nullable types, nested objects, and arrays in tool schemas -- Properly maps tool choices (`auto`, `required`, `none`) to Google's configuration -- Preserves model preferences from MCP sampling parameters - -The handler joins the existing Anthropic and OpenAI handlers, providing a consistent interface for model-agnostic sampling across providers. - -### Concurrent Tool Execution in Sampling - -When an LLM returns multiple tool calls in a single sampling response, they can now be executed concurrently ([#3022](https://github.com/PrefectHQ/fastmcp/pull/3022)). Default behavior remains sequential; opt in with `tool_concurrency`. Tools can declare `sequential=True` to force sequential execution even when concurrency is enabled. - -```python -result = await context.sample( - messages="Fetch weather for NYC and LA", - tools=[fetch_weather], - tool_concurrency=0, # Unlimited parallel execution -) -``` - -### OpenAPI `validate_output` Option - -`OpenAPIProvider` and `FastMCP.from_openapi()` now accept `validate_output=False` to skip output schema validation ([#3134](https://github.com/PrefectHQ/fastmcp/pull/3134)). Useful when backends don't conform to their own OpenAPI response schemas — structured JSON still flows through, only the strict schema checking is disabled. - -```python -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - validate_output=False, -) -``` - -### Auth Token Injection and Azure OBO Dependencies - -New dependency injection for accessing the authenticated user's token directly in tool parameters ([#2918](https://github.com/PrefectHQ/fastmcp/pull/2918)). Works with any auth provider. - -```python -from fastmcp.server.dependencies import CurrentAccessToken, TokenClaim -from fastmcp.server.auth import AccessToken - -@mcp.tool() -async def my_tool( - token: AccessToken = CurrentAccessToken, - user_id: str = TokenClaim("oid"), -): ... -``` - -For Azure/Entra, the new `fastmcp[azure]` extra adds `EntraOBOToken`, which handles the On-Behalf-Of token exchange declaratively: - -```python -from fastmcp.server.auth.providers.azure import EntraOBOToken - -@mcp.tool() -async def get_emails( - graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), -): - # graph_token is ready — OBO exchange happened automatically - ... -``` - -### `generate-cli` Agent Skill Generation - -`fastmcp generate-cli` now produces a `SKILL.md` alongside the CLI script ([#3115](https://github.com/PrefectHQ/fastmcp/pull/3115)) — a Claude Code agent skill with pre-computed invocation syntax for every tool. Agents reading the skill can call tools immediately without running `--help`. On by default; pass `--no-skill` to opt out. - -### Background Task Notification Queue - -Background tasks now use a distributed Redis notification queue for reliable delivery ([#2906](https://github.com/PrefectHQ/fastmcp/pull/2906)). Elicitation switches from polling to BLPOP (single blocking call instead of ~7,200 round-trips/hour), and notification delivery retries up to 3x with TTL-based expiration. - -### Async Auth Checks - -Auth check functions can now be `async`, enabling authorization decisions that depend on asynchronous operations like reading server state via `Context.get_state` or calling external services ([#3150](https://github.com/PrefectHQ/fastmcp/issues/3150)). Sync and async checks can be freely mixed. Previously, passing an async function as an auth check would silently pass (coroutine objects are truthy). - -### Optional `$ref` Dereferencing in Schemas - -Schema `$ref` dereferencing — which inlines all `$defs` for compatibility with MCP clients that don't handle `$ref` — is now controlled by the `dereference_schemas` constructor kwarg ([#3141](https://github.com/PrefectHQ/fastmcp/issues/3141)). Default is `True` (dereference on) because the non-compliant clients are popular and the failure mode is silent breakage that server authors can't diagnose. Opt out when you know your clients handle `$ref` and want smaller schemas: - -```python -mcp = FastMCP("my-server", dereference_schemas=False) -``` - -Dereferencing is implemented as middleware (`DereferenceRefsMiddleware`) that runs at serve-time, so schemas are stored with `$ref` intact and only inlined when sent to clients. - -### Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed - -Sixteen deprecated keyword arguments have been removed from `FastMCP.__init__`. Passing any of them now raises `TypeError` with a migration hint. Environment variables (e.g., `FASTMCP_HOST`) continue to work — only the constructor kwargs moved. - -**Transport/server settings** (`host`, `port`, `log_level`, `debug`, `sse_path`, `message_path`, `streamable_http_path`, `json_response`, `stateless_http`): Pass to `run()`, `run_http_async()`, or `http_app()` as appropriate, or set via environment variables. - -```python -# Before -mcp = FastMCP("server", host="0.0.0.0", port=8080) -mcp.run() - -# After -mcp = FastMCP("server") -mcp.run(transport="http", host="0.0.0.0", port=8080) -``` - -**Duplicate handling** (`on_duplicate_tools`, `on_duplicate_resources`, `on_duplicate_prompts`): Use the unified `on_duplicate=` parameter. - -**Tag filtering** (`include_tags`, `exclude_tags`): Use `server.enable(tags=..., only=True)` and `server.disable(tags=...)` after construction. - -**Tool serializer** (`tool_serializer`): Return `ToolResult` from tools instead. - -**Tool transformations** (`tool_transformations`): Use `server.add_transform(ToolTransform(...))` after construction. - -The `_deprecated_settings` attribute and `.settings` property are also removed. `ExperimentalSettings` has been deleted (dead code). - -### Breaking: `ui=` Renamed to `app=` - -The MCP Apps decorator parameter has been renamed from `ui=ToolUI(...)` / `ui=ResourceUI(...)` to `app=AppConfig(...)` ([#3117](https://github.com/PrefectHQ/fastmcp/pull/3117)). `ToolUI` and `ResourceUI` are consolidated into a single `AppConfig` class. Wire format is unchanged. See the MCP Apps section under beta2 for full details. -## 3.0.0beta2 - -### CLI: `fastmcp list` and `fastmcp call` - -New client-side CLI commands for querying and invoking tools on any MCP server — remote URLs, local Python files, MCPConfig JSON, or arbitrary stdio commands. Especially useful for giving LLMs that don't have built-in MCP support access to MCP tools via shell commands. - -```bash -# Discover tools on a server -fastmcp list http://localhost:8000/mcp -fastmcp list server.py -fastmcp list --command 'npx -y @modelcontextprotocol/server-github' - -# Call a tool -fastmcp call server.py greet name=World -fastmcp call http://localhost:8000/mcp search query=hello limit=5 -fastmcp call server.py create_item '{"name": "Widget", "tags": ["a", "b"]}' -``` - -Key features: -- Tool arguments are auto-coerced using the tool's JSON schema (`limit=5` → int) -- Single JSON objects work as positional args alongside `key=value` and `--input-json` -- `--input-schema` / `--output-schema` for full JSON schemas, `--json` for machine-readable output -- `--transport sse` for SSE servers, `--command` for stdio servers -- Auto OAuth for HTTP targets (no-ops if server doesn't require auth) -- Fuzzy tool name matching suggests alternatives on typos -- Interactive terminal elicitation for tools that request user input mid-execution - -Documentation: [CLI Querying](https://gofastmcp.com/v3/cli/client) - -### CLI: `fastmcp discover` and name-based resolution - -`fastmcp discover` scans editor configs (Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose) and project-level `mcp.json` files for MCP server definitions. Discovered servers can be referenced by name — or `source:name` for precision — in `fastmcp list` and `fastmcp call`. - -```bash -# See all configured servers -fastmcp discover - -# Use a server by name -fastmcp list weather -fastmcp call weather get_forecast city=London - -# Target a specific source with source:name -fastmcp list claude-code:my-server -fastmcp call cursor:weather get_forecast city=London - -# Filter discovery to specific sources -fastmcp discover --source claude-code --source cursor -``` - -Documentation: [CLI Querying](https://gofastmcp.com/v3/cli/client) - -### CLI: Expanded Reload File Watching - -The `--reload` flag now watches a comprehensive set of file types, making it suitable for MCP apps with frontend bundles ([#3028](https://github.com/PrefectHQ/fastmcp/pull/3028)). Previously limited to `.py` files, it now watches JavaScript, TypeScript, HTML, CSS, config files, and media assets. - -### CLI: fastmcp install stdio - -The new `fastmcp install stdio` command generates full `uv run` commands for running FastMCP servers over stdio ([#3032](https://github.com/PrefectHQ/fastmcp/pull/3032)). - -```bash -# Generate command for a server -fastmcp install stdio server.py - -# Outputs: -# uv run --directory /path/to/project fastmcp run server.py -``` - -The command automatically detects the project directory and generates the appropriate `uv run` invocation, making it easy to integrate FastMCP servers with MCP clients. - -### CIMD (Client ID Metadata Documents) - -CIMD provides an alternative to Dynamic Client Registration for OAuth-authenticated MCP servers. Instead of registering with each server dynamically, clients host a static JSON document at an HTTPS URL. That URL becomes the client's `client_id`, and servers verify identity through domain ownership. - -**Client usage:** - -```python -from fastmcp import Client -from fastmcp.client.auth import OAuth - -async with Client( - "https://mcp-server.example.com/mcp", - auth=OAuth( - client_metadata_url="https://myapp.example.com/oauth/client.json", - ), -) as client: - await client.ping() -``` - -The `OAuth` helper now supports deferred binding — `mcp_url` is optional when using `OAuth` with `Client(auth=...)`, since the transport provides the server URL automatically. - -**CLI tools for document management:** - -```bash -# Generate a CIMD document -fastmcp auth cimd create --name "My App" \ - --redirect-uri "http://localhost:*/callback" \ - --client-id "https://myapp.example.com/oauth/client.json" \ - --output client.json - -# Validate a hosted document -fastmcp auth cimd validate https://myapp.example.com/oauth/client.json -``` - -**Server-side support:** - -CIMD is enabled by default on `OAuthProxy` and its provider subclasses (GitHub, Google, etc.). The server-side implementation includes SSRF-hardened document fetching with DNS pinning, dual redirect URI validation (both CIMD document patterns and proxy patterns must match), HTTP cache-aware revalidation, and `private_key_jwt` assertion validation for clients that need stronger authentication than public client auth. - -Key details: -- CIMD URLs must be HTTPS with a non-root path -- `token_endpoint_auth_method` limited to `none` or `private_key_jwt` (no shared secrets) -- `redirect_uris` in CIMD documents support wildcard port patterns (`http://localhost:*/callback`) -- Servers fetch and cache documents with standard HTTP caching (ETag, Last-Modified, Cache-Control) -- CIMD is a protocol-level feature — any auth provider implementing the spec can support it - -Documentation: [CIMD Authentication](https://gofastmcp.com/v3/clients/auth/cimd), [OAuth Proxy CIMD config](https://gofastmcp.com/v3/servers/auth/oauth-proxy#cimd-support) - -### Pre-Registered OAuth Clients - -The `OAuth` client helper now accepts `client_id` and `client_secret` parameters for servers where the client is already registered ([#3086](https://github.com/PrefectHQ/fastmcp/pull/3086)). This bypasses Dynamic Client Registration entirely — useful when DCR is disabled, or when the server has pre-provisioned credentials for your application. - -```python -from fastmcp import Client -from fastmcp.client.auth import OAuth - -async with Client( - "https://mcp-server.example.com/mcp", - auth=OAuth( - client_id="my-registered-app", - client_secret="my-secret", - scopes=["read", "write"], - ), -) as client: - await client.ping() -``` - -The static credentials are injected before the OAuth flow begins, so the client never attempts DCR. If the server rejects the credentials, the error surfaces immediately rather than retrying with fresh registration (which can't help for fixed credentials). Public clients can omit `client_secret`. - -Documentation: [Pre-Registered Clients](https://gofastmcp.com/v3/clients/auth/oauth#pre-registered-clients) - -### CLI: `fastmcp generate-cli` - -`fastmcp generate-cli` connects to any MCP server, reads its tool schemas, and writes a standalone Python CLI script where every tool becomes a typed subcommand with flags, help text, and tab completion ([#3065](https://github.com/PrefectHQ/fastmcp/pull/3065)). The insight is that MCP tool schemas already contain everything a CLI framework needs — parameter names, types, descriptions, required/optional status — so the generator maps JSON Schema directly into [cyclopts](https://cyclopts.readthedocs.io/) commands. - -```bash -# Generate from any server spec -fastmcp generate-cli weather -fastmcp generate-cli http://localhost:8000/mcp -fastmcp generate-cli server.py my_weather_cli.py - -# Use the generated script -python my_weather_cli.py call-tool get_forecast --city London --days 3 -python my_weather_cli.py list-tools -python my_weather_cli.py read-resource docs://readme -``` - -The generated script embeds the resolved transport (URL or stdio command), so it's self-contained — users don't need to know about MCP or FastMCP to use it. Supports `-f` to overwrite existing files, and name-based resolution via `fastmcp discover`. - -Documentation: [Generate CLI](https://gofastmcp.com/v3/cli/generate-cli) - -### CLI: Goose Integration - -New `fastmcp install goose` command that generates a `goose://extension?...` deeplink URL and opens it, prompting Goose to install the server as a STDIO extension ([#3040](https://github.com/PrefectHQ/fastmcp/pull/3040)). Goose requires `uvx` rather than `uv run`, so the command builds the appropriate invocation automatically. - -```bash -fastmcp install goose server.py -fastmcp install goose server.py --with pandas --python 3.11 -``` - -Also adds a full integration guide at [Goose Integration](https://gofastmcp.com/v3/integrations/goose). - -### ResponseLimitingMiddleware - -New middleware for controlling tool response sizes, preventing large outputs from overwhelming LLM context windows ([#3072](https://github.com/PrefectHQ/fastmcp/pull/3072)). Text responses are truncated at UTF-8 character boundaries; structured responses (tools with `output_schema`) raise `ToolError` since truncation would corrupt the schema. - -```python -from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware - -# Limit all tool responses to 500KB -mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) - -# Limit only specific tools, raise errors instead of truncating -mcp.add_middleware(ResponseLimitingMiddleware( - max_size=100_000, - tools=["search", "fetch_data"], - raise_on_unstructured=True, -)) -``` - -Key features: -- Configurable size limit (default 1MB) -- Tool-specific filtering via `tools` parameter -- Size metadata added to result's `meta` field for monitoring -- Configurable `raise_on_structured` and `raise_on_unstructured` behavior - -Documentation: [Middleware](https://gofastmcp.com/v3/servers/middleware) - -### Background Task Context (SEP-1686) - -`Context` now works transparently in background tasks running in Docket workers ([#2905](https://github.com/PrefectHQ/fastmcp/pull/2905)). Previously, tools running as background tasks couldn't use `ctx.elicit()` because there was no active request context. Now, when a tool executes in a Docket worker, `Context` detects this via its `task_id` and routes elicitation through Redis-based coordination: the task sets its status to `input_required`, sends a `notifications/tasks/updated` notification with elicitation metadata, and waits for the client to respond via `tasks/sendInput`. - -```python -@mcp.tool(task=True) -async def interactive_task(ctx: Context) -> str: - # Works transparently in both foreground and background task modes - result = await ctx.elicit("Please provide additional input", str) - - if isinstance(result, AcceptedElicitation): - return f"You provided: {result.data}" - else: - return "Elicitation was declined or cancelled" -``` - -`ctx.is_background_task` and `ctx.task_id` are available for tools that need to branch on execution mode. - -### `require_auth` Removed - -The `require_auth` authorization check introduced in beta1 has been removed in favor of scope-based authorization via `require_scopes` ([#3103](https://github.com/PrefectHQ/fastmcp/pull/3103)). Since configuring an `AuthProvider` already rejects unauthenticated requests at the transport level, `require_auth` was redundant — `require_scopes` provides the same guarantee with better granularity. The beta1 Component Authorization section has been updated to reflect this. - -### MCP Apps (SDK Compatibility) - -Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) — the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet — those are future phases. - -**Breaking change from beta 2:** The `ui=` parameter on `@mcp.tool()` and `@mcp.resource()` has been renamed to `app=`, and the `ToolUI`/`ResourceUI` classes have been consolidated into a single `AppConfig` class. This follows the established `task=True`/`TaskConfig` pattern. The wire format (`meta["ui"]`, `_meta.ui`) is unchanged. - -**Registering tools with app metadata:** - -```python -from fastmcp import FastMCP -from fastmcp.apps import AppConfig, ResourceCSP, ResourcePermissions - -mcp = FastMCP("My Server") - -# Register the HTML bundle as a ui:// resource with CSP -@mcp.resource( - "ui://my-app/view.html", - app=AppConfig( - csp=ResourceCSP(resource_domains=["https://unpkg.com"]), - permissions=ResourcePermissions(clipboard_write={}), - ), -) -def app_html() -> str: - from pathlib import Path - return Path("./dist/index.html").read_text() - -# Tool with UI — clients render an iframe alongside the result -@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) -async def list_users() -> list[dict]: - return [{"id": "1", "name": "Alice"}] - -# App-only tool — visible to the UI but hidden from the model -@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html", visibility=["app"])) -async def delete_user(id: str) -> dict: - return {"deleted": True} -``` - -The `app=` parameter accepts `True` (enable with defaults), an `AppConfig` instance, or a raw dict for forward compatibility. It merges into `meta["ui"]` — alongside any other metadata you set. - -**`ui://` resources** automatically get the correct MIME type (`text/html;profile=mcp-app`) unless you override it explicitly. - -**Extension negotiation**: The server advertises `io.modelcontextprotocol/ui` in `capabilities.extensions`. UI metadata (`_meta.ui`) always flows through to clients — the MCP Apps spec assigns visibility enforcement to the host, not the server. Tools can check whether the connected client supports a given extension at runtime via `ctx.client_supports_extension()`: - -```python -from fastmcp import Context -from fastmcp.apps import AppConfig, UI_EXTENSION_ID - -@mcp.tool(app=AppConfig(resource_uri="ui://dashboard")) -async def dashboard(ctx: Context) -> dict: - data = compute_dashboard() - if ctx.client_supports_extension(UI_EXTENSION_ID): - return data - return {"summary": format_text(data)} -``` - -**Key details:** -- `AppConfig` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional). On resources, `resource_uri` and `visibility` are validated as not-applicable and will raise `ValueError` if set. -- `csp` accepts a `ResourceCSP` model with structured domain lists: `connect_domains`, `resource_domains`, `frame_domains`, `base_uri_domains` -- `permissions` accepts a `ResourcePermissions` model: `camera`, `microphone`, `geolocation`, `clipboard_write` (each set to `{}` to request) -- `AppConfig` uses `extra="allow"` for forward compatibility with future spec additions -- Models use Pydantic aliases for wire format (`resourceUri`, `prefersBorder`, `connectDomains`, `clipboardWrite`) -- Resource metadata (including CSP/permissions) is propagated to `resources/read` response content items so hosts can read it when rendering the iframe -- `ctx.client_supports_extension(id)` is a general-purpose method — works for any extension, not just MCP Apps -- `structuredContent` in tool results already works via `ToolResult` — MCP Apps clients use this to pass data into the iframe -- The server does not strip `_meta.ui` for non-UI clients; per the spec, visibility enforcement is the host's responsibility - -**Future phases** will add a component DSL for building UIs declaratively, an in-repo renderer, and a `FastMCPApp` class. - -Implementation: `fastmcp_slim/fastmcp/apps/config.py` (models and constants), with integration points in `server.py` (decorator parameters), `low_level.py` (extension advertisement), and `context.py` (`client_supports_extension` method). - ---- - -## 3.0.0beta1 - -### Provider-Based Architecture - -v3.0 introduces a provider-based component system that replaces v2's static-only registration ([#2622](https://github.com/PrefectHQ/fastmcp/pull/2622)). Providers dynamically source tools, resources, templates, and prompts at runtime. - -**Core abstraction** (`fastmcp_slim/fastmcp/server/providers/base.py`): -```python -class Provider: - async def list_tools(self) -> Sequence[Tool]: ... - async def get_tool(self, name: str) -> Tool | None: ... - async def list_resources(self) -> Sequence[Resource]: ... - async def get_resource(self, uri: str) -> Resource | None: ... - async def list_resource_templates(self) -> Sequence[ResourceTemplate]: ... - async def get_resource_template(self, uri: str) -> ResourceTemplate | None: ... - async def list_prompts(self) -> Sequence[Prompt]: ... - async def get_prompt(self, name: str) -> Prompt | None: ... -``` - -Providers support: -- **Lifecycle management**: `async def lifespan()` for setup/teardown -- **Visibility control**: `enable()` / `disable()` with name, version, tags, components, and allowlist mode -- **Transform stacking**: `provider.add_transform(Namespace(...))`, `provider.add_transform(ToolTransform(...))` - -### LocalProvider - -`LocalProvider` (`fastmcp_slim/fastmcp/server/providers/local_provider.py`) manages components registered via decorators. Can be used standalone and attached to multiple servers: - -```python -from fastmcp.server.providers import LocalProvider - -provider = LocalProvider() - -@provider.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -# Attach to multiple servers -server1 = FastMCP("Server1", providers=[provider]) -server2 = FastMCP("Server2", providers=[provider]) -``` - -### ProxyProvider - -`ProxyProvider` (`fastmcp_slim/fastmcp/server/providers/proxy.py`) proxies components from remote MCP servers via a client factory. Used by `create_proxy()` and `FastMCP.mount()` for remote server integration. - -```python -from fastmcp.server import create_proxy - -# Create proxy to remote server -server = create_proxy("http://remote-server/mcp") -``` - -### OpenAPIProvider - -`OpenAPIProvider` (`fastmcp_slim/fastmcp/server/providers/openapi/provider.py`) creates MCP components from OpenAPI specifications. Routes map HTTP operations to tools, resources, or templates based on configurable rules. - -```python -from fastmcp.server.providers.openapi import OpenAPIProvider -import httpx - -client = httpx.AsyncClient(base_url="https://api.example.com") -provider = OpenAPIProvider(openapi_spec=spec, client=client) - -mcp = FastMCP("API Server", providers=[provider]) -``` - -Features: -- Automatic route-to-component mapping (GET → resource, POST/PUT/DELETE → tool) -- Custom route mappings via `route_maps` or `route_map_fn` -- Component customization via `mcp_component_fn` -- Name collision detection and handling - -### FastMCPProvider - -`FastMCPProvider` (`fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py`) wraps a FastMCP server to enable mounting one server onto another. Components delegate execution through the wrapped server's middleware chain. - -```python -from fastmcp import FastMCP -from fastmcp.server.providers import FastMCPProvider -from fastmcp.server.transforms import Namespace - -main = FastMCP("Main") -sub = FastMCP("Sub") - -@sub.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -# Mount with namespace -provider = FastMCPProvider(sub) -provider.add_transform(Namespace("sub")) -main.add_provider(provider) -# Tool accessible as "sub_greet" -``` - -### Transforms - -Transforms modify components (tools, resources, prompts) as they flow from providers to clients ([#2836](https://github.com/PrefectHQ/fastmcp/pull/2836)). They use a middleware pattern where each transform receives a `call_next` callable to continue the chain. - -**Built-in transforms** (`fastmcp_slim/fastmcp/server/transforms/`): - -- `Namespace` - adds prefixes to names (`tool` → `api_tool`) and path segments to URIs (`data://x` → `data://api/x`) -- `ToolTransform` - modifies tool schemas (rename, description, tags, argument transforms) -- `Visibility` - sets visibility state on components by key or tag (backs `enable()`/`disable()` API) -- `VersionFilter` - filters components by version range (`version_gte`, `version_lt`) -- `ResourcesAsTools` - exposes resources as tools for tool-only clients -- `PromptsAsTools` - exposes prompts as tools for tool-only clients - -```python -from fastmcp.server.transforms import Namespace, ToolTransform -from fastmcp.tools.tool_transform import ToolTransformConfig - -provider = SomeProvider() -provider.add_transform(Namespace("api")) -provider.add_transform(ToolTransform({ - "api_verbose_tool_name": ToolTransformConfig(name="short") -})) - -# Stacking composes transformations -# "foo" → "api_foo" (namespace) → "short" (rename) -``` - -**Custom transforms** subclass `Transform` and override needed methods: - -```python -from collections.abc import Sequence -from fastmcp.server.transforms import Transform, GetToolNext -from fastmcp.tools import Tool - -class TagFilter(Transform): - def __init__(self, required_tags: set[str]): - self.required_tags = required_tags - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - return [t for t in tools if t.tags & self.required_tags] - - async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: - tool = await call_next(name) - return tool if tool and tool.tags & self.required_tags else None -``` - -Transforms apply at two levels: -- **Provider-level**: `provider.add_transform()` - affects only that provider's components -- **Server-level**: `server.add_transform()` - affects all components from all providers - -Documentation: `docs/servers/transforms/transforms.mdx`, `docs/servers/visibility.mdx` - -### ResourcesAsTools and PromptsAsTools - -These transforms expose resources and prompts as tools for clients that only support the tools protocol. Each transform generates two tools that provide listing and access functionality. - -**ResourcesAsTools** generates `list_resources` and `read_resource` tools: - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import ResourcesAsTools - -mcp = FastMCP("Server") - -@mcp.resource("data://config") -def get_config() -> dict: - return {"setting": "value"} - -mcp.add_transform(ResourcesAsTools(mcp)) -# Now has list_resources and read_resource tools -``` - -The `list_resources` tool returns JSON with resource metadata. The `read_resource` tool accepts a URI and returns the resource content, preserving both text and binary data through base64 encoding. - -**PromptsAsTools** generates `list_prompts` and `get_prompt` tools: - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import PromptsAsTools - -mcp = FastMCP("Server") - -@mcp.prompt -def analyze_code(code: str, language: str = "python") -> str: - return f"Analyze this {language} code:\n{code}" - -mcp.add_transform(PromptsAsTools(mcp)) -# Now has list_prompts and get_prompt tools -``` - -The `list_prompts` tool returns JSON with prompt metadata including argument information. The `get_prompt` tool accepts a prompt name and optional arguments dict, returning the rendered prompt as a messages array. Non-text content (like embedded resources) is preserved as structured JSON. - -Both transforms: -- Capture a provider reference at construction for deferred querying -- Route through `FastMCP.read_resource()` / `FastMCP.render_prompt()` when the provider is FastMCP, ensuring middleware chains execute -- Fall back to direct provider methods for plain providers -- Return JSON for easy parsing by tool-only clients - -Documentation: `docs/servers/transforms/resources-as-tools.mdx`, `docs/servers/transforms/prompts-as-tools.mdx` - ---- - -### Session-Scoped State - -v3.0 changes context state from request-scoped to session-scoped. State now persists across multiple tool calls within the same MCP session. - -```python -@mcp.tool -async def increment_counter(ctx: Context) -> int: - count = await ctx.get_state("counter") or 0 - await ctx.set_state("counter", count + 1) - return count + 1 -``` - -State is automatically keyed by session ID, ensuring isolation between different clients. The implementation uses [pykeyvalue](https://github.com/strawgate/py-key-value) for pluggable storage backends: - -```python -from key_value.aio.stores.redis import RedisStore - -# Use Redis for distributed deployments -mcp = FastMCP("server", session_state_store=RedisStore(...)) -``` - -**Key details:** -- Methods are now async: `await ctx.get_state()`, `await ctx.set_state()`, `await ctx.delete_state()` -- State expires after 1 day (TTL) to prevent unbounded memory growth -- Works during `on_initialize` middleware when using the same session object -- For distributed HTTP, session identity comes from the `mcp-session-id` header - -Documentation: `docs/servers/context.mdx` - ---- - -### Visibility System - -Components can be enabled/disabled using the visibility system. Each `enable()` or `disable()` call adds a stateless Visibility transform that marks components via internal metadata. Later transforms override earlier ones. - -```python -mcp = FastMCP("Server") - -# Disable by name and component type -mcp.disable(names={"dangerous_tool"}, components=["tool"]) - -# Disable by tag -mcp.disable(tags={"admin"}) - -# Disable by version -mcp.disable(names={"old_tool"}, version="1.0", components=["tool"]) - -# Allowlist mode - only show components with these tags -mcp.enable(tags={"public"}, only=True) - -# Enable overrides earlier disable (later transform wins) -mcp.disable(tags={"internal"}) -mcp.enable(names={"safe_tool"}) # safe_tool is visible despite internal tag -``` - -Works at both server and provider level. Supports: -- **Blocklist mode** (default): All components visible except explicitly disabled -- **Allowlist mode** (`only=True`): Only explicitly enabled components visible -- **Tag-based filtering**: Enable/disable groups of components by tag -- **Override semantics**: Later transforms override earlier marks (enable after disable = enabled) -- **Transform ordering**: Visibility transforms are injected at the point you call them, so component state is known - -#### Per-Session Visibility - -Server-level visibility changes affect all connected clients. For per-session control, use `Context` methods that apply rules only to the current session ([#2917](https://github.com/PrefectHQ/fastmcp/pull/2917)): - -```python -from fastmcp import FastMCP -from fastmcp.server.context import Context - -mcp = FastMCP("Server") - -@mcp.tool(tags={"premium"}) -def premium_analysis(data: str) -> str: - return f"Premium analysis of: {data}" - -@mcp.tool -async def unlock_premium(ctx: Context) -> str: - """Unlock premium features for this session only.""" - await ctx.enable_components(tags={"premium"}) - return "Premium features unlocked" - -@mcp.tool -async def reset_features(ctx: Context) -> str: - """Reset to default feature set.""" - await ctx.reset_visibility() - return "Features reset to defaults" - -# Globally disabled - sessions unlock individually -mcp.disable(tags={"premium"}) -``` - -Session visibility methods: -- `await ctx.enable_components(...)`: Enable components for this session -- `await ctx.disable_components(...)`: Disable components for this session -- `await ctx.reset_visibility()`: Clear session rules, return to global defaults - -Session rules override global transforms. FastMCP automatically sends `ToolListChangedNotification` (and resource/prompt equivalents) to affected sessions when visibility changes. - -Documentation: `docs/servers/visibility.mdx` - ---- - -### Component Versioning - -v3.0 introduces versioning support for tools, resources, and prompts. Components can declare a version, and when multiple versions of the same component exist, the highest version is automatically exposed to clients. - -**Declaring versions:** - -```python -@mcp.tool(version="1.0") -def add(x: int, y: int) -> int: - return x + y - -@mcp.tool(version="2.0") -def add(x: int, y: int, z: int = 0) -> int: - return x + y + z - -# Only v2.0 is exposed to clients via list_tools() -# Calling "add" invokes the v2.0 implementation -``` - -**Version comparison:** -- Uses PEP 440 semantic versioning (1.10 > 1.9 > 1.2) -- Falls back to string comparison for non-PEP 440 versions (dates like `2025-01-15` work) -- Unversioned components sort lower than any versioned component -- The `v` prefix is normalized (`v1.0` equals `1.0`) - -**Version visibility in meta:** - -List operations expose all available versions in the component's `meta` field: - -```python -tools = await client.list_tools() -# Each tool's meta includes: -# - meta["fastmcp"]["version"]: the version of this component ("2.0") -# - meta["fastmcp"]["versions"]: all available versions ["2.0", "1.0"] -``` - -**Retrieving and calling specific versions:** - -```python -# Get the highest version (default) -tool = await server.get_tool("add") - -# Get a specific version -tool_v1 = await server.get_tool("add", version="1.0") - -# Call a specific version -result = await server.call_tool("add", {"x": 1, "y": 2}, version="1.0") -``` - -**Client version requests:** - -The FastMCP client supports version selection: - -```python -async with Client(server) as client: - # Call specific tool version - result = await client.call_tool("add", {"x": 1, "y": 2}, version="1.0") - - # Get specific prompt version - prompt = await client.get_prompt("my_prompt", {"text": "..."}, version="2.0") -``` - -For generic MCP clients, pass version via `_meta` in arguments: - -```json -{ - "x": 1, - "y": 2, - "_meta": { - "fastmcp": { - "version": "1.0" - } - } -} -``` - -**VersionFilter transform:** - -The `VersionFilter` transform enables serving different API versions from a single codebase: - -```python -from fastmcp import FastMCP -from fastmcp.server.providers import LocalProvider -from fastmcp.server.transforms import VersionFilter - -# Define components on a shared provider -components = LocalProvider() - -@components.tool(version="1.0") -def calculate(x: int, y: int) -> int: - return x + y - -@components.tool(version="2.0") -def calculate(x: int, y: int, z: int = 0) -> int: - return x + y + z - -# Create servers that share the provider with different filters -api_v1 = FastMCP("API v1", providers=[components]) -api_v1.add_transform(VersionFilter(version_lt="2.0")) - -api_v2 = FastMCP("API v2", providers=[components]) -api_v2.add_transform(VersionFilter(version_gte="2.0")) -``` - -Parameters mirror comparison operators: -- `version_gte`: Versions >= this value pass through -- `version_lt`: Versions < this value pass through - -**Key format:** - -Component keys now include a version suffix using `@` as a delimiter: -- Versioned: `tool:add@1.0`, `resource:data://config@2.0` -- Unversioned: `tool:add@`, `resource:data://config@` - -The `@` is always present (even for unversioned components) to enable unambiguous parsing of URIs that may contain `@`. - ---- - -### Type-Safe Canonical Results - -v3.0 introduces type-safe result classes that provide explicit control over component responses while supporting MCP runtime metadata: `ToolResult` ([#2736](https://github.com/PrefectHQ/fastmcp/pull/2736)), `ResourceResult` ([#2734](https://github.com/PrefectHQ/fastmcp/pull/2734)), and `PromptResult` ([#2738](https://github.com/PrefectHQ/fastmcp/pull/2738)). - -#### ToolResult - -`ToolResult` (`fastmcp_slim/fastmcp/tools/tool.py:79`) provides structured tool responses: - -```python -from fastmcp.tools import ToolResult - -@mcp.tool -def process(data: str) -> ToolResult: - return ToolResult( - content=[TextContent(type="text", text="Done")], - structured_content={"status": "success", "count": 42}, - meta={"processing_time_ms": 150} - ) -``` - -Fields: -- `content`: List of MCP ContentBlocks (text, images, etc.) -- `structured_content`: Dict matching tool's output schema -- `meta`: Runtime metadata passed to MCP as `_meta` - -#### ResourceResult - -`ResourceResult` (`fastmcp_slim/fastmcp/resources/resource.py:117`) provides structured resource responses: - -```python -from fastmcp.resources import ResourceResult, ResourceContent - -@mcp.resource("data://items") -def get_items() -> ResourceResult: - return ResourceResult( - contents=[ - ResourceContent({"key": "value"}), # auto-serialized to JSON - ResourceContent(b"binary data"), - ], - meta={"count": 2} - ) -``` - -Accepts strings, bytes, or `list[ResourceContent]` for flexible content handling. - -#### PromptResult - -`PromptResult` (`fastmcp_slim/fastmcp/prompts/prompt.py:109`) provides structured prompt responses: - -```python -from fastmcp.prompts import PromptResult, Message - -@mcp.prompt -def conversation() -> PromptResult: - return PromptResult( - messages=[ - Message("What's the weather?"), - Message("It's sunny today.", role="assistant"), - ], - meta={"generated_at": "2024-01-01"} - ) -``` - ---- - -### Background Tasks (SEP-1686) - -v3.0 implements MCP SEP-1686 for background task execution via Docket integration. - -**Configuration** (`fastmcp_slim/fastmcp/server/tasks/config.py`): - -```python -from fastmcp.utilities.tasks import TaskConfig - -@mcp.tool(task=TaskConfig(mode="required")) -async def long_running_task(): - # Must be executed as background task - ... - -@mcp.tool(task=TaskConfig(mode="optional")) -async def flexible_task(): - # Supports both sync and task execution - ... - -@mcp.tool(task=True) # Shorthand for mode="optional" -async def simple_task(): - ... -``` - -Task modes: -- `"forbidden"`: Component does not support task execution (default) -- `"optional"`: Supports both synchronous and task execution -- `"required"`: Must be executed as background task - -Requires Docket server for task scheduling and result polling. - ---- - -### Decorators Return Functions - -v3.0 changes what decorators (`@tool`, `@resource`, `@prompt`) return ([#2856](https://github.com/PrefectHQ/fastmcp/pull/2856)). Decorators now return the original function unchanged, rather than transforming it into a component object. - -**v3 behavior (default):** -```python -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -# greet is still your function - call it directly -greet("World") # "Hello, World!" -``` - -**Why this matters:** -- Functions stay callable - useful for testing and reuse -- Instance methods just work: `mcp.add_tool(obj.method)` -- Matches how Flask, FastAPI, and Typer decorators behave - -**For v2 compatibility:** - -```python -import fastmcp - -# v2 behavior: decorators return FunctionTool/FunctionResource/FunctionPrompt objects -fastmcp.settings.decorator_mode = "object" -``` - -Environment variable: `FASTMCP_DECORATOR_MODE=object` - ---- - -### CLI Auto-Reload - -The `--reload` flag enables file watching with automatic server restarts for development ([#2816](https://github.com/PrefectHQ/fastmcp/pull/2816)). - -```bash -# Watch for changes and restart -fastmcp run server.py --reload - -# Watch specific directories -fastmcp run server.py --reload --reload-dir ./src --reload-dir ./lib - -# Works with any transport -fastmcp run server.py --reload --transport http --port 8080 -``` - -Implementation (`fastmcp_slim/fastmcp/cli/run.py`): -- Uses `watchfiles` for efficient file monitoring -- Runs server as subprocess for clean restarts -- Stateless mode for seamless reconnection after restart -- stdio: Full MCP features including elicitation -- HTTP: Limited bidirectional features during reload - -Also available with `fastmcp dev inspector`: -```bash -fastmcp dev inspector server.py # Includes --reload by default -``` - ---- - -### Component Authorization - -v3.0 introduces callable-based authorization for tools, resources, and prompts ([#2855](https://github.com/PrefectHQ/fastmcp/pull/2855)). - -**Component-level auth**: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_scopes - -mcp = FastMCP() - -@mcp.tool(auth=require_scopes("write")) -def protected_tool(): ... - -@mcp.resource("data://secret", auth=require_scopes("read")) -def secret_data(): ... - -@mcp.prompt(auth=require_scopes("admin")) -def admin_prompt(): ... -``` - -**Server-wide auth via middleware**: - -```python -from fastmcp.server.middleware import AuthMiddleware -from fastmcp.server.auth import require_scopes, restrict_tag - -# Require specific scope for all components -mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) - -# Tag-based restrictions -mcp = FastMCP(middleware=[ - AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) -]) -``` - -Built-in checks: -- `require_scopes(*scopes)`: Requires specific OAuth scopes -- `restrict_tag(tag, scopes)`: Requires scopes only for tagged components - -Custom checks receive `AuthContext` with `token` and `component`: - -```python -def custom_check(ctx: AuthContext) -> bool: - return ctx.token is not None and "admin" in ctx.token.scopes -``` - -STDIO transport bypasses all auth checks (no OAuth concept). - ---- - -### FileSystemProvider - -v3.0 introduces `FileSystemProvider`, a fundamentally different approach to organizing MCP servers. Instead of importing a server instance and decorating functions with `@server.tool`, you use standalone decorators in separate files and let the provider discover them. - -**The problem it solves**: Traditional servers require coordination between files—either tool files import the server (creating coupling) or the server imports all tool modules (creating a registry bottleneck). FileSystemProvider removes this coupling entirely. - -**Usage** ([#2823](https://github.com/PrefectHQ/fastmcp/pull/2823)): - -```python -from fastmcp import FastMCP -from fastmcp.server.providers import FileSystemProvider - -# Scans mcp/ directory for decorated functions -mcp = FastMCP("server", providers=[FileSystemProvider("mcp/")]) -``` - -**Tool files are self-contained**: - -```python -# mcp/tools/greet.py -from fastmcp.tools import tool - -@tool -def greet(name: str) -> str: - """Greet someone by name.""" - return f"Hello, {name}!" -``` - -Features: -- **Standalone decorators**: `@tool`, `@resource`, `@prompt` from `fastmcp.tools`, `fastmcp.resources`, `fastmcp.prompts` ([#2832](https://github.com/PrefectHQ/fastmcp/pull/2832)) -- **Reload mode**: `FileSystemProvider("mcp/", reload=True)` re-scans on every request for development -- **Package support**: Directories with `__init__.py` support relative imports -- **Warning deduplication**: Broken imports warn once per file modification - -Documentation: [FileSystemProvider](https://gofastmcp.com/v3/servers/providers/filesystem) - ---- - -### SkillsProvider - -v3.0 introduces `SkillsProvider` for exposing agent skills as MCP resources ([#2944](https://github.com/PrefectHQ/fastmcp/pull/2944)). Skills are directories containing instructions and supporting files that teach AI assistants how to perform tasks—used by Claude Code, Cursor, VS Code Copilot, and other AI coding tools. - -**Usage**: - -```python -from pathlib import Path -from fastmcp import FastMCP -from fastmcp.server.providers.skills import SkillsDirectoryProvider - -mcp = FastMCP("Skills Server") -mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills")) -``` - -Each subdirectory with a `SKILL.md` file becomes a discoverable skill. Clients see: -- `skill://{name}/SKILL.md` - Main instruction file -- `skill://{name}/_manifest` - JSON listing of all files with sizes and hashes -- `skill://{name}/{path}` - Supporting files (via template or resources) - -**Two-layer architecture**: -- `SkillProvider` - Handles a single skill folder -- `SkillsDirectoryProvider` - Scans directories, creates a `SkillProvider` per valid skill - -**Vendor providers** with locked default paths: - -| Provider | Directory | -|----------|-----------| -| `ClaudeSkillsProvider` | `~/.claude/skills/` | -| `CursorSkillsProvider` | `~/.cursor/skills/` | -| `VSCodeSkillsProvider` | `~/.copilot/skills/` | -| `CodexSkillsProvider` | `/etc/codex/skills/`, `~/.codex/skills/` | -| `GeminiSkillsProvider` | `~/.gemini/skills/` | -| `GooseSkillsProvider` | `~/.config/agents/skills/` | -| `CopilotSkillsProvider` | `~/.copilot/skills/` | -| `OpenCodeSkillsProvider` | `~/.config/opencode/skills/` | - -**Progressive disclosure**: By default, supporting files are hidden from `list_resources()` and accessed via template. Set `supporting_files="resources"` for full enumeration. - -Documentation: [Skills Provider](https://gofastmcp.com/v3/servers/providers/skills) - ---- - -### OpenTelemetry Tracing - -v3.0 adds OpenTelemetry instrumentation for observability into server and client operations ([#2869](https://github.com/PrefectHQ/fastmcp/pull/2869)). - -**Server spans**: Created for tool calls, resource reads, and prompt renders with attributes including component key, provider type, session ID, and auth context. - -**Client spans**: Wrap outgoing calls with W3C trace context propagation via request meta. - -```python -# Tracing is passive - configure an OTel SDK to export spans -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - -provider = TracerProvider() -provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) -trace.set_tracer_provider(provider) - -# Use fastmcp normally - spans export to your configured backend -``` - -Components provide their own span attributes through a `get_span_attributes()` method that subclasses override—this lets LocalProvider, FastMCPProvider, and ProxyProvider each include relevant context (original names, backend URIs, etc.). - -Documentation: [Telemetry](https://gofastmcp.com/v3/servers/telemetry) - ---- - -### Pagination - -v3.0 adds pagination support for list operations when servers expose many components ([#2903](https://github.com/PrefectHQ/fastmcp/pull/2903)). - -```python -from fastmcp import FastMCP - -# Enable pagination with 50 items per page -server = FastMCP("ComponentRegistry", list_page_size=50) -``` - -When `list_page_size` is set, `tools/list`, `resources/list`, `resources/templates/list`, and `prompts/list` paginate responses with `nextCursor` for subsequent pages. - -**Client behavior**: The FastMCP Client fetches all pages automatically—`list_tools()` and similar methods return the complete list. For manual pagination (memory constraints, progress reporting), use `_mcp` variants: - -```python -async with Client(server) as client: - result = await client.list_tools_mcp() - while result.next_cursor: - result = await client.list_tools_mcp(cursor=result.next_cursor) -``` - -Documentation: [Pagination](https://gofastmcp.com/v3/servers/pagination) - ---- - -### Composable Lifespans - -Lifespans can be combined with the `|` operator for modular setup/teardown ([#2828](https://github.com/PrefectHQ/fastmcp/pull/2828)): - -```python -from fastmcp import FastMCP -from fastmcp.server.lifespan import lifespan - -@lifespan -async def db_lifespan(server): - db = await connect_db() - try: - yield {"db": db} - finally: - await db.close() - -@lifespan -async def cache_lifespan(server): - cache = await connect_cache() - try: - yield {"cache": cache} - finally: - await cache.close() - -mcp = FastMCP("server", lifespan=db_lifespan | cache_lifespan) -``` - -Both enter lifespans in order and exit in reverse (LIFO). Context dicts are merged. - -Also adds `combine_lifespans()` utility for FastAPI integration: - -```python -from fastmcp.utilities.lifespan import combine_lifespans - -app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) -``` - -Documentation: [Lifespan](https://gofastmcp.com/v3/servers/lifespan) - ---- - -### Tool Timeout - -Tools can limit foreground execution time with a `timeout` parameter ([#2872](https://github.com/PrefectHQ/fastmcp/pull/2872)): - -```python -@mcp.tool(timeout=30.0) -async def fetch_data(url: str) -> dict: - """Fetch with 30-second timeout.""" - ... -``` - -When exceeded, clients receive MCP error code `-32000`. Both sync and async tools are supported—sync functions run in thread pools so the timeout applies regardless of execution model. - -Note: This timeout applies to foreground execution only. Background tasks (`task=True`) execute in Docket workers where this timeout isn't enforced. - ---- - -### PingMiddleware - -Sends periodic server-to-client pings to keep long-lived connections alive ([#2838](https://github.com/PrefectHQ/fastmcp/pull/2838)): - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware import PingMiddleware - -mcp = FastMCP("server") -mcp.add_middleware(PingMiddleware(interval_ms=5000)) -``` - -The middleware starts a background ping task on first message from each session, using the session's existing task group for automatic cleanup when the session ends. - ---- - -### Context.transport Property - -Tools can detect which transport is active ([#2850](https://github.com/PrefectHQ/fastmcp/pull/2850)): - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP("example") - -@mcp.tool -def my_tool(ctx: Context) -> str: - if ctx.transport == "stdio": - return "short response" - return "detailed response with more context" -``` - -Returns `Literal["stdio", "sse", "streamable-http"]` when running, or `None` outside a server context. - ---- - -### Automatic Threadpool for Sync Functions - -Synchronous tools, resources, and prompts now automatically run in a threadpool, preventing event loop blocking during concurrent requests ([#2865](https://github.com/PrefectHQ/fastmcp/pull/2865)): - -```python -import time - -@mcp.tool -def slow_tool(): - time.sleep(10) # No longer blocks other requests - return "done" -``` - -Three concurrent calls now execute in parallel (~10s) rather than sequentially (30s). Uses `anyio.to_thread.run_sync()` which properly propagates contextvars, so `Context` and `Depends` continue to work. - ---- - -### CLI Update Notifications - -The CLI notifies users when a newer FastMCP version is available on PyPI ([#2840](https://github.com/PrefectHQ/fastmcp/pull/2840)). - -**Setting**: `FASTMCP_CHECK_FOR_UPDATES` -- `"stable"` - Check for stable releases (default) -- `"prerelease"` - Include alpha/beta/rc versions -- `"off"` - Disable - -12-hour cache, 2-second timeout, fails silently on network errors. - ---- - -### Deprecated Features - -These emit deprecation warnings but continue to work. - -#### Mount Prefix Parameter - -The `prefix` parameter for `mount()` renamed to `namespace`: - -```python -# Deprecated -main.mount(subserver, prefix="api") - -# New -main.mount(subserver, namespace="api") -``` - -#### Tag Filtering, Tool Serializer, Tool Transformations Init Parameters - -These constructor parameters have been **removed** (not just deprecated) as of rc1. See "Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed" in the rc1 section above. The `add_tool_transformation()` and `remove_tool_transformation()` methods remain as deprecated shims. - ---- - -### Breaking Changes - -#### WSTransport Removed - -The deprecated `WSTransport` client transport has been removed ([#2826](https://github.com/PrefectHQ/fastmcp/pull/2826)). Use `StreamableHttpTransport` instead. - -#### Decorators Return Functions - -Decorators (`@tool`, `@resource`, `@prompt`) now return the original function instead of component objects. Code that treats the decorated function as a `FunctionTool`, `FunctionResource`, or `FunctionPrompt` will break. - -```python -# v2.x -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -isinstance(greet, FunctionTool) # True - -# v3.0 -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -isinstance(greet, FunctionTool) # False -callable(greet) # True - it's still your function -greet("World") # "Hello, World!" -``` - -Set `FASTMCP_DECORATOR_MODE=object` or `fastmcp.settings.decorator_mode = "object"` for v2 behavior. - -#### Component Enable/Disable Moved to Server/Provider - -The `enabled` field and `enable()`/`disable()` methods removed from component objects: - -```python -# v2.x -tool = await server.get_tool("my_tool") -tool.disable() - -# v3.0 -server.disable(names={"my_tool"}, components=["tool"]) -``` - -#### Component Lookup Methods - -Server lookup and listing methods have updated signatures: - -- Parameter names: `get_tool(name=...)`, `get_resource(uri=...)`, etc. (was `key`) -- Plural listing methods renamed: `get_tools()` → `list_tools()`, `get_resources()` → `list_resources()`, etc. -- Return types: `list_tools()`, `list_resources()`, etc. return lists instead of dicts - -```python -# v2.x -tools = await server.get_tools() -tool = tools["my_tool"] - -# v3.0 -tools = await server.list_tools() -tool = next((t for t in tools if t.name == "my_tool"), None) -``` - -#### Prompt Return Types - -Prompt functions now use `Message` instead of `mcp.types.PromptMessage`: - -```python -# v2.x -from mcp.types import PromptMessage, TextContent - -@mcp.prompt -def my_prompt() -> PromptMessage: - return PromptMessage(role="user", content=TextContent(type="text", text="Hello")) - -# v3.0 -from fastmcp.prompts import Message - -@mcp.prompt -def my_prompt() -> Message: - return Message("Hello") # role defaults to "user" -``` - -#### Auth Provider Environment Variables Removed - -Auth providers no longer auto-load from environment variables ([#2752](https://github.com/PrefectHQ/fastmcp/pull/2752)): - -```python -# v2.x - auto-loaded from FASTMCP_SERVER_AUTH_GITHUB_* -auth = GitHubProvider() - -# v3.0 - explicit configuration -import os -auth = GitHubProvider( - client_id=os.environ["GITHUB_CLIENT_ID"], - client_secret=os.environ["GITHUB_CLIENT_SECRET"], -) -``` - -See `dev-docs/v3-notes/auth-provider-env-vars.md` for rationale. - -#### Server Banner Environment Variable - -`FASTMCP_SHOW_CLI_BANNER` → `FASTMCP_SHOW_SERVER_BANNER` ([#2771](https://github.com/PrefectHQ/fastmcp/pull/2771)) - -Now applies to all server startup methods, not just the CLI. - -#### Context State Methods Are Async - -`ctx.set_state()` and `ctx.get_state()` are now async and session-scoped: - -```python -# v2.x -ctx.set_state("key", "value") -value = ctx.get_state("key") - -# v3.0 -await ctx.set_state("key", "value") -value = await ctx.get_state("key") -``` - -State now persists across requests within a session. See "Session-Scoped State" above. diff --git a/dev-docs/v4-notes/background-tasks.md b/dev-docs/v4-notes/background-tasks.md deleted file mode 100644 index 125c8ba73..000000000 --- a/dev-docs/v4-notes/background-tasks.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -title: Background Tasks (SEP-2663) ---- - -**Status: Shipped (#4602, #4603).** This page is the approved design for rebuilding FastMCP's background-task support on the `io.modelcontextprotocol/tasks` extension. It supersedes the earlier "delete the task machinery" direction recorded during the SDK v2 migration. The [Feature Program](feature-program.md#background-tasks-sep-2663) carries the one-line status; user-facing usage is documented at [Background Tasks](https://gofastmcp.com/servers/tasks) and [Background Tasks (client)](https://gofastmcp.com/clients/tasks). - -## TL;DR - -Background tasks live on. The MCP spec moved them out of core and into a **Final, merged** extension — `io.modelcontextprotocol/tasks` (SEP-2663) — that keeps the polling model FastMCP already implements. **No SDK, in any language, ships a runtime for it yet.** FastMCP owns the only production-shaped execution engine (Docket/Redis) built for a near-identical protocol. - -The plan: **rebuild task support on SEP-2663 as `fastmcp-tasks`, an in-repo optional package**, gated by `task=True` exactly as MCP Apps is gated by `app=True`. Remove the SEP-1686 *wire layer*; keep and re-home the *execution engine*. Along the way, introduce a **FastMCP-native server extension API** so tasks (and later Apps) plug in through one documented mechanism instead of bespoke surgery on core. - -Net effect: a server that already uses `@mcp.tool(task=True)` needs **no code change**, and FastMCP plausibly becomes the first runtime implementation of the tasks extension anywhere. - -## Background: where tasks stand today - -FastMCP 3 shipped background tasks against **SEP-1686**, the task protocol that briefly lived in the core MCP spec. The implementation is ~4,000 lines across server, client, CLI, and an SDK shim, split into two very different halves: - -- **A wire layer** — capability advertisement, the `tasks/get|result|list|cancel` handlers, a `CreateTaskResult` on augmented `tools/call`, and a Redis-backed *push* relay that lets a worker reach a client to deliver notifications and elicitation requests. -- **An execution engine** — [Docket](https://github.com/chrisguidry/docket) (queue, worker, result store, TTL, `memory://` or `redis://` backends) plus FastMCP-built durability: auth-scoped compound keys that isolate task access by caller, request-context snapshot/restore across worker processes, argument-coercion parity with the sync path, and the `fastmcp tasks worker` CLI. - -The SDK v2 migration removed SEP-1686 from the core spec. The v4 design notes, until now, recorded the consequence as "delete the task machinery; users who need tasks stay on FastMCP 3." That was the right call **given the information at the time** — the assumption was that the successor protocol either didn't exist or wasn't implementable. Both halves of that assumption turned out to be wrong. - -## What changed upstream: SEP-2663 - -Tasks were reworked, not removed. **SEP-2663 ("Tasks Extension") is Final and was merged upstream on 2026-05-15**, superseding SEP-1686. It defines the `io.modelcontextprotocol/tasks` extension, a capability-negotiated feature layered on the SEP-2133 extensions mechanism. It keeps SEP-1686's polling core and tightens it. - -**The wire shape:** - -1. Client advertises the tasks capability (per-request, in `_meta`). This is *consent* — "I can handle a task result" — not a request to run one. -2. Client issues a normal `tools/call`. **The server decides** whether to run it as a task. -3. If tasked, the server returns a `CreateTaskResult` (a claimed result shape carrying `resultType: "task"`) with a **server-generated** `taskId`. -4. Client polls `tasks/get` until the status is terminal; the result is **inlined** into that response. -5. In-task input (elicit/sample/roots requested *during* execution) is **poll-based**: status flips to `input_required`, outstanding requests appear in an `inputRequests` map, and the client answers via `tasks/update`. -6. `tasks/cancel` is cooperative. Optional push exists (`notifications/tasks` over `subscriptions/listen`) but servers need not send it. - -**Delta from SEP-1686** — and the striking thing is that most of it is *deletion*, because the spec moved toward what FastMCP already built: - -| Dimension | SEP-1686 (old) | SEP-2663 (new) | FastMCP today | -| --- | --- | --- | --- | -| Task-id generation | Client-generated | **Server**-generated | Already server-generated | -| `tasks/list` | Present | **Removed** (enumeration risk) | Already a stub returning `[]` | -| Result retrieval | Separate `tasks/result` | **Inlined** into `tasks/get` | Merge two handlers into one | -| `tasks/delete` | Present | **Removed** (rely on TTL) | TTL is Docket-native | -| Creation race | `notifications/tasks/created` | **Durable-creation MUST** | One read-your-writes check away | -| In-task input | Push relay + `_meta` tagging | **Poll**: `input_required` + `tasks/update` | Replaces the hairiest module | -| Statuses | 7 (incl. `submitted`, `unknown`) | 5 | Shrinks a mapping table | -| Augmentable requests | Any | **`tools/call` only** | Tools-only surface (see scope) | -| LB routing | Unspecified | `Mcp-Name: <taskId>` header | Moot with shared Redis | - -**Critically: no runtime exists.** The `ext-tasks` repo is schema + prose only. The TypeScript and Python SDKs carry the wire types and conformance fixtures — no client/server implementation. The field is open. - -## The decision - -**Build it.** Two facts flip the earlier "delete and wait" call: - -1. **The spec is what FastMCP already implements**, minus a push relay it can now shed. The rebuild is dominated by deletion and a thin new wire adapter, not a from-scratch effort. -2. **FastMCP is uniquely positioned.** SEP-2663 *assumes* a durable server-side store, server-minted high-entropy ids, eventual-consistency-aware creation, and multi-node routing — precisely what Docket/Redis provides. No other framework has this built. - -Maintaining the SEP-1686 machinery through the migration is dead weight (it's the sole reason for the `_sdk_patches.py` shim, the `TaskNotificationHandler`, and a cluster of protocol-era xfails). Rebuilding on SEP-2663 clears that debt *and* produces a flagship v4 capability with a zero-code-change migration story. - -## Architecture - -### Engine and wire split - -The existing code already separates cleanly along this line; the rebuild makes the boundary a package boundary. - -- **Removed:** the SEP-1686 wire layer — capability advertisement, the four CRUD handlers, and (the big win) the entire Redis push relay (`server/tasks/elicitation.py`, `notifications.py`), which existed only because SEP-1686 had no poll-based in-task input channel. SEP-2663's `input_required`/`tasks/update` replaces it; the request/response store survives, the push envelope does not. -- **Kept and re-homed:** the Docket execution engine, the auth-scoped key encoding (this is our *authorization* layer for `tasks/get`/`update`/`cancel` — stronger than the spec's "taskIds may be bearer tokens"), context snapshot/restore, argument coercion, and the worker CLI. All of it is wire-agnostic. -- **New:** a thin SEP-2663 wire adapter — capability, the `tasks/get`/`update`/`cancel` methods, and a `tools/call` interceptor that decides-and-tasks. - -### Packaging - -`fastmcp-tasks` becomes an in-repo `uv` workspace member on the `fastmcp_remote` template (own `pyproject.toml`, lockstep-versioned, re-exported through the `fastmcp` metapackage). The DX parallel with MCP Apps is exact: - -| Concern | MCP Apps | Background tasks | -| --- | --- | --- | -| Authoring flag (core) | `@mcp.tool(app=True)` | `@mcp.tool(task=True)` | -| Optional package | `prefab-ui` | `fastmcp-tasks` | -| Extra | `fastmcp[apps]` | `fastmcp[tasks]` | -| Missing-package behavior | Loud install hint | Loud install hint at server build | - -**Core keeps only the declaration:** `task=True` / `TaskConfig` is metadata on a component, with no engine import. Everything else — engine and wire adapter — lives in the `fastmcp-tasks` package. The existing `[tasks]` extra re-points from the SEP-1686 machinery to `fastmcp-tasks`, so `pip install fastmcp[tasks]` and `task=True` keep working with modern wire underneath. - -Activation stays **implicit-but-loud** (the existing `require_docket()` pattern, not silent degradation): `task=True` anywhere triggers a lazy import of `fastmcp-tasks` at build time; a missing install raises immediately. A tool the author marked as a task silently running inline would be a correctness bug, not a graceful fallback. - -### The extension API - -MCP extensions (SEP-2133) are a **genuinely new abstraction in SDK v2** — they did not exist in v1. So MCP Apps hand-rolling its integration wasn't a wrong choice; it predates the tool. Today FastMCP's **server** bypasses the SDK's `Extension` class entirely (it hand-splices the `ui` capability onto the low-level server and walks tool metadata directly), while the **client** forwards `ClientExtension` natively. Every new protocol extension currently means bespoke core surgery. - -Tasks is the forcing function to fix that. The design adds a single registration point: - -```python test="skip" -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension - -mcp = FastMCP("Server") -mcp.add_extension(TasksExtension(url="redis://...")) # required to enable tasks - - -@mcp.tool(task=True) # intent: this tool CAN run as a task -async def crunch(dataset: str) -> str: - ... -``` - -`add_extension` is **required** for `task=True` to work — it is not autodetected from the presence of `task=True` flags. This is deliberate. The extension needs configuration that has to live somewhere (backend URL, worker concurrency, TTL defaults), and `add_extension(TasksExtension(...))` is its natural home; autodetection would only scatter that config into settings/env and hide the moment of enablement. Requiring it also keeps capability advertisement honest — the server advertises the `tasks` capability iff the extension is registered — and removes the worst footgun, a tool silently running on an in-memory backend in production because nobody configured Redis. The two concerns stay cleanly separated: `task=True` is per-component intent ("this tool *can* be a task"); `add_extension` is server-wide enablement and config ("this server *runs* tasks, here's how"). Using `task=True` with no extension registered is a loud build-time error. - -The extension API contributes a negotiated capability, additive request methods, and a `tools/call` interceptor — with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is **designed against tasks** because tasks exercises the full surface (capability + methods + interception + client claims + notifications), where Apps exercises only a subset. Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices and confirming the design generalizes. - -**Extension vs. middleware** — the discriminator, so we do not over-apply this: an extension is a *negotiated contract change the client must understand*; middleware is *unilateral server behavior the client never sees*. PII detection, auth, rate limiting → [middleware](https://gofastmcp.com/servers/middleware). Tasks, Apps → extensions. Litmus test: delete the capability advertisement — if nothing about the client's behavior changes, it was middleware. - -### Client experience - -SEP-2663 removed the client-side "make this a task" flag — the server decides. That maps onto FastMCP's existing two-tier client surface, the **friendly** `call_tool` vs the **low-level** `call_tool_mcp`, so there is almost no new API: - -- **`call_tool(name, args)` (friendly)** — advertises the capability and, if the server tasks the call, **transparently drives the poll loop** and returns the finished result. Whether the server tasked it is invisible. The machinery already exists: the migration wired claim-resolution through `call_tool_mcp`'s `allow_claimed` path, so a returned `CreateTaskResult` is finished into an ordinary `CallToolResult`. In-task `input_required` routes through the client's **existing elicitation handler**, answered via `tasks/update` — so background elicitation looks identical to foreground elicitation, with zero new client API. -- **`call_tool_mcp(...)` (low-level)** — hands back the raw `CreateTaskResult` claimed shape for callers managing the task themselves. -- **A "return quickly" flag on the friendly interface** yields the `Task` handle (`.status()`, `.wait()`, `.cancel()`, awaitable) without blocking — the escape hatch for progress and cancellation. - -Server-side, `TaskConfig` modes translate directly: `required` → always task (`-32003` for non-declaring clients), `optional` → task iff the client declared, `forbidden` → never. - -## Sequencing - -1. **Design + unit-test the extension API** against tasks' full surface (capability, methods, interception, client claims/notifications) — as its own testable layer, proven in isolation with a trivial in-test extension before any tasks logic lands on it. -2. **Build `fastmcp-tasks`** — extract the engine from the removed SEP-1686 layer, write the SEP-2663 adapter, port the client half. -3. **Migrate MCP Apps onto the extension API** — fast-follow, off the critical path, with Apps' existing green tests as the regression net. - -Tasks leads because only it exercises the full API surface; leading with the Apps subset would design us into a corner. Apps becomes the second consumer that confirms generality. - -## Scope for v1 (non-goals) - -- **Polling only.** The optional `notifications/tasks` push and `subscriptions/listen` integration are deferred to a later `fastmcp-tasks` version. This lets the second Redis notification queue die rather than be ported. -- **`tools/call` only — do not lead the spec.** SEP-2663 augments `tools/call` only. FastMCP 3 offered `task=True` on prompts and resources *ahead* of the SDK under SEP-1686, and that was a mistake: it produced wire-inexpressible capability, a permanent xfail cluster, and the sdk-feedback #3 gap. The rebuild does **not** repeat it — `task=` is a tools-only surface, and the generic prompt/resource task spine is dropped rather than carried. If the spec extends augmentation later, the surface grows with it. -- **Ship experimental.** The `ext-tasks` schema is labeled experimental with no releases; `fastmcp-tasks` ships labeled experimental initially and revs on its own cadence when the schema moves. - -## Risks - -| Risk | Mitigation | -| --- | --- | -| **Spec churn** (extension is experimental) | Thin wire adapter over a wire-agnostic engine; ship experimental; SEP itself is Final, so the polling model is stable even if field names move. | -| **Era gating** — SDK strips `capabilities.extensions` at pre-2026 negotiated versions (sdk-feedback #2) | Advertisement effectively requires the 2026-07-28 era. FastMCP 3 covers legacy tasks. **#2 now gates a flagship feature → escalate upstream.** | -| **Co-developing a new abstraction + greenfield feature** | Build and unit-test the extension API in isolation first (step 1) before tasks logic lands on it. | -| **Naming confusion** — `[tasks]` extra re-points under the same name | Deliberate changelog note; user code and the extra name are unchanged, only the wire modernizes. | - -## Design decisions (resolved) - -These were the open forks; the maintainer has settled them. Recorded here so the direction is unambiguous going into implementation. - -1. **Wire adapter location — in the `fastmcp-tasks` package.** The engine *and* the SEP-2663 wire adapter live in the package; core carries only the `task=True` declaration. This isolates the experimental schema's churn from core, at the cost of diverging from the Apps precedent (where the `ui` wire glue lives in core today — Apps will converge onto this model when it migrates to the extension API). -2. **Extension API shape — a FastMCP-native `mcp.add_extension()`, required to enable tasks.** Chosen over a thin pass-through to the SDK's `MCPServer(extensions=...)` because the FastMCP-native API can hand extensions the `Context`, component registry, and auth scope the SDK's `Extension` withholds. `add_extension` is **required** for `task=True` (not autodetected) — it is the single home for backend config and the honest source of capability advertisement. See [The extension API](#the-extension-api). -3. **Client default — transparent completion on the friendly interface.** `call_tool` drives the poll loop and returns the finished result; `call_tool_mcp` exposes the raw `CreateTaskResult`; a "return quickly" flag yields the `Task` handle. See [Client experience](#client-experience). -4. **Experimental labeling — yes.** `fastmcp-tasks` ships labeled experimental for at least one minor cycle, tracking the experimental `ext-tasks` schema. -5. **Resource/prompt spine — dropped; tools-only.** The rebuild does not lead the SDK on augmentable request types, correcting the SEP-1686-era mistake. See [Scope for v1](#scope-for-v1-non-goals). diff --git a/dev-docs/v4-notes/change-register.md b/dev-docs/v4-notes/change-register.md deleted file mode 100644 index eb1db6549..000000000 --- a/dev-docs/v4-notes/change-register.md +++ /dev/null @@ -1,595 +0,0 @@ ---- -title: Change Register ---- - -This is the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), organized by subsystem. It doubles as a review lens: take one subsystem, read its claimed changes, and verify each against the diff. - -Each entry is tagged **Absorbed** (public surface unchanged), **Bridged** (shim keeps old code working, usually warning), **Breaking** (user code must change), or **Deprecated** (works, warns, slated for removal). See the [overview](index.md) for what each disposition means. - -**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures were the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction — and the first of those went away when the stable SDK restored `mcp.types` (below). Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 29 `_ALIASES` bridge entries warn correctly with actionable messages. - -## Environment - -### Dependency floors: pydantic >= 2.12, Starlette >= 1.0 — Breaking (environment) - -The SDK v2 raises FastMCP's dependency floors. Projects pinning an older pydantic (e.g. `2.11.*`) hit an unsatisfiable-resolution error at install time and must bump their pin; unpinned projects get pydantic upgraded silently. The server extra floors Starlette at `>=1.0.1` — modern FastAPI (0.11x+) already runs Starlette 1.x, so coexistence is clean (verified with FastAPI 0.138.2); only very old FastAPI pinned below Starlette 1.0 conflicts. Both are documented in the [upgrade guide's Environment requirements](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3#environment-requirements). - -*Verify:* `fastmcp_slim/pyproject.toml` (`pydantic[email]>=2.12.0` core, `starlette>=1.0.1` server extra); WS2 environment-upgrade scenario. - -## Types and imports - -The SDK v2 moved protocol types into a standalone `mcp_types` package — still importable as `mcp.types` — and renamed every model field from camelCase to snake_case in Python. The wire format is unchanged: the models keep their camelCase aliases and the SDK serializes with `by_alias=True`, so this renames the attributes code reads, not the JSON on the connection. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it. - -### `mcp.types` split into `mcp_types` — Breaking (by omission) - -<Note> -Superseded by the stable SDK — see "`mcp.types` restored as a permanent alias" below. The betas this section was written against had no `mcp.types`; `2.0.0` brought it back, so the break never reached a release. -</Note> - -The `mcp.types` module no longer exists. Any `from mcp.types import X` or `import mcp.types` in user code raises `ImportError`. This is the one import change users cannot avoid. - -*Verify:* `fastmcp_slim/fastmcp/types.py`, and grep the diff for the doc migration `from mcp.types import` → `from fastmcp.types import` (30 sites). - -### `mcp.types` restored as a permanent alias — Absorbed (stable-SDK change) - -The SDK betas removed `mcp.types` outright, which made user imports the one unavoidable break in the migration. SDK `2.0.0` reintroduced it as a permanent alias for `mcp_types`: a wildcard mirror where every name is the *same object* (`mcp.types.Tool is mcp_types.Tool`), with matching `__all__` and the same snake_case fields. It is not a v1 restoration — only the import path came back. So `from mcp.types import X` keeps working, and the break is gone. - -This leaves the two spellings pointing at one package, and FastMCP uses each in a different place on purpose: - -- **User-facing docs and examples use `mcp.types`.** Anyone installing `fastmcp` gets the full SDK (`fastmcp` → `fastmcp-slim[client,server]` → `[mcp]` → `mcp`), so the aliased path always resolves and is the spelling the SDK prefers. It also means a user's own dependency list needs only `mcp`, without naming `mcp-types` to satisfy a linter. -- **FastMCP's own source uses `mcp_types`.** `mcp.types` is a submodule of `mcp`, so importing it requires the whole SDK. `mcp-types` is a *core* `fastmcp-slim` dependency while `mcp` sits behind the `[mcp]` extra, and a bare `fastmcp-slim` install must import without the SDK present — a guarantee `test_bare_slim_import_needs_only_mcp_types` pins. Reaching for `mcp.types` in core modules (`exceptions.py`, `_compat.py`, `tools/`, `resources/`) would pull the full SDK into the slim floor and break it. - -The rule of thumb: import `mcp_types` in library code, write `mcp.types` in anything a user copies. Both resolve to the same objects, so neither choice constrains the other. - -*Verify:* `.venv/.../mcp/types/__init__.py` (the wildcard mirror), `fastmcp_slim/pyproject.toml` (`mcp-types` core vs `mcp` in the `[mcp]` extra), `tests/client/test_slim_package_boundaries.py::test_bare_slim_import_needs_only_mcp_types`, and `tests/test_upgrade_from_v3.py::TestRemovedSurfacesFailLoudly::test_mcp_types_import_path_restored_by_stable_sdk`. - -### `fastmcp.types` is the stable home — Bridged - -<Note> -Superseded before release — see "`fastmcp.types` trimmed to FastMCP-unique types only" below. This section documents the re-export set as it existed mid-migration; none of it ever shipped. -</Note> - -FastMCP re-exports the protocol types users are most likely to touch from `fastmcp.types`, sourced from `mcp_types` (the `mcp` root package lacks most of them): - -```python test="skip" -from fastmcp.types import TextContent, Tool, ToolAnnotations, ErrorData -``` - -The re-export set is deliberately limited to names that trace to a documented user import: `TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`, `ResourceLink`, `ContentBlock`, `Tool`, `Resource`, `ResourceTemplate`, `Prompt`, `PromptMessage`, `CallToolResult`, `GetPromptResult`, `ReadResourceResult`, `TextResourceContents`, `BlobResourceContents`, `SamplingMessage`, `CreateMessageResult`, `SamplingCapability`, `Root`, `ErrorData`, `Completion`, `Annotations`, `ToolAnnotations`, `Icon`, `ToolResultContent`, plus the pre-existing `Textarea`. Notification and request wrapper types (e.g. `ToolListChangedNotification`) are not re-exported — import those from `mcp_types` directly. - -*Verify:* `fastmcp_slim/fastmcp/types.py` `__all__`. - -### `fastmcp.types` trimmed to FastMCP-unique types only — Absorbed (post-review cleanup) - -The re-export set above never shipped in a release, so it was cut before 4.0 rather than deprecated. `fastmcp.types` now holds only types FastMCP defines itself — `Textarea` — and every bare `mcp_types` mirror (`TextContent`, `Tool`, `ToolAnnotations`, `ErrorData`, and the rest of the 29-name list) is gone. Code that imported those from `fastmcp.types` now imports them from `mcp_types` directly: - -```python -from mcp_types import TextContent, Tool, ToolAnnotations, ErrorData -``` - -Because `fastmcp.types.__all__` was `["Textarea"]` as of the last stable release (v3.4.4) and the mirrors were added only in this unreleased migration work, removing them breaks no released user — there is no bridge or deprecation warning to write. - -*Verify:* `fastmcp_slim/fastmcp/types.py` `__all__` (back down to `["Textarea"]`). - -### camelCase field reads are bridged — Bridged (deprecated) - -Objects FastMCP hands back — results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to sampling and elicitation handlers — are SDK v2 objects with snake_case fields. A compatibility bridge installed at import time routes the old camelCase names to their snake_case fields, warning once per read: - -```python -from fastmcp import Client - - -async def read_schema(): - async with Client("my_mcp_server.py") as client: - tools = await client.list_tools() - return tools[0].inputSchema # works, warns; prefer .input_schema -``` - -The bridged fields are exactly those users read, data-driven from an `_ALIASES` table: `inputSchema`/`outputSchema` (Tool); `readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint` (ToolAnnotations); `mimeType` (Resource, ResourceTemplate, TextResourceContents, BlobResourceContents, ImageContent, AudioContent) and `uriTemplate` (ResourceTemplate); `isError`/`structuredContent` (CallToolResult); `hasMore` (Completion); `serverInfo`/`protocolVersion` (InitializeResult); `nextCursor`/`resourceTemplates` (List\*Result); `systemPrompt`/`maxTokens`/`stopSequences`/`modelPreferences`/`toolChoice` (CreateMessageRequestParams); `requestedSchema` (ElicitRequestFormParams). WS2 verified all 29 alias entries warn correctly with actionable messages. - -*Verify:* `fastmcp_slim/fastmcp/_compat.py` (the `_ALIASES` table and `install()`). - -### The bridge is a genuine runtime toggle — Absorbed (post-review fix) - -The bridge properties install unconditionally, and each getter reads the live `mcp_camelcase_compat` setting on every access: warn-and-return when enabled, raise `AttributeError` when disabled. An earlier version installed the bridge once at import, so flipping the setting afterward did nothing — commit `d9659453` fixed this so the toggle works at runtime: - -```python -import fastmcp - -fastmcp.settings.mcp_camelcase_compat = False # now takes effect immediately -``` - -The setting is documented in [Settings](https://gofastmcp.com/more/settings) as `FASTMCP_MCP_CAMELCASE_COMPAT`. - -*Verify:* `fastmcp_slim/fastmcp/settings.py` (setting), `fastmcp_slim/fastmcp/_compat.py` (per-read gate), commit `d9659453`. - -### `mcp-types` is now a core slim dependency — Absorbed (post-review fix) - -Bare `import fastmcp` loads `mcp_types` via `_sdk_patches` and `_compat`, so a bare `fastmcp-slim` install (without the `[mcp]` extra) hit `ModuleNotFoundError`. Because `mcp-types` only pulls `pydantic` and `typing-extensions` (already core), it was promoted to a core dependency while the full `mcp` SDK stays in the `[mcp]` extra. - -*Verify:* `fastmcp_slim/pyproject.toml` (`mcp-types==2.0.0b1` in core dependencies), commit `e16ffad4`. - -### `McpError` is an alias; construction changed — Bridged (catch) / Breaking (construct) - -`fastmcp.exceptions.McpError` is a plain alias of the SDK's `MCPError` — a plain alias, not a subclass, so `except McpError` still catches SDK-raised errors and `err.error.code` still reads: - -```python -from fastmcp.exceptions import McpError - -try: - ... -except McpError as err: - print(err.error.code) # unchanged -``` - -Construction is the one unavoidable behavior break. The v1 pattern of wrapping an `ErrorData` positionally raises `TypeError` under v2; construct with keywords instead: - -```python -from fastmcp.exceptions import McpError - -# Before (raises TypeError under SDK v2): -# raise McpError(ErrorData(code=-32000, message="Client not supported")) - -raise McpError(code=-32000, message="Client not supported") -``` - -*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`McpError = MCPError`). - -## Server core - -The SDK v2 rewrote the server request-handling model. FastMCP's handler layer is the most heavily rewritten part of the migration, but the public server API is unchanged. - -### Handler adapters — Absorbed - -Handlers are now registered by method string via `add_request_handler(method, params_type, handler)`, take a uniform `(ctx, params)` signature, and return the **bare** result model (no `ServerResult` wrapper). FastMCP's `_setup_handlers` builds one thin adapter per method (`tools/list`, `tools/call`, `resources/read`, `prompts/get`, `logging/setLevel`, …) that binds the request context, adapts params to the existing handler body, and returns the bare result. The v1 decorator overrides and `_wrap_list_handler` are deleted. - -*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (462 lines changed), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`. - -### FastMCP-owned request context — Absorbed - -The SDK's `request_ctx` ContextVar is gone; the SDK passes context to handlers as an argument only. FastMCP owns its own `fastmcp_request_ctx` ContextVar, set at the top of every adapter. It stores a FastMCP-owned `FastMCPRequestContext` wrapper rather than the raw SDK context, because the raw `ServerRequestContext.meta` is a bare `TypedDict` carrying only `progress_token` — the full `_meta` block (which holds `_meta.fastmcp.version` and the distributed-trace parent) has to be lifted out of the raw params dict. `Context.request_context` and its consumers (`report_progress`, `session_id`, telemetry trace extraction, `get_http_request`) all read through the wrapper. - -*Verify:* `fastmcp_slim/fastmcp/server/dependencies.py`, `server/context.py`, `server/telemetry.py`. - -### `ServerMiddleware` bridge for `initialize` — Absorbed - -Server-side middleware is a new first-class SDK concept: `Server.middleware` is a list of `ServerMiddleware` composed around every request and notification, including `initialize`. FastMCP no longer subclasses `ServerSession` (the runner constructs it), so the old `MiddlewareServerSession._received_request` override is gone. A `FastMCPServerMiddleware` is appended to the SDK's middleware list (preserving the SDK's own OpenTelemetry middleware) and intercepts `initialize` to run FastMCP's middleware chain. The v2 interface is cleaner — `call_next(ctx)` returns the serialized result directly, so the old `capturing_respond` machinery is deleted. - -*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware`). - -### Middleware observes every inbound message — New (coverage) - -FastMCP's `Middleware` chain used to begin *inside* the per-method handlers, so `on_message`/`on_request`/`on_notification` only fired for messages that reached a tool/resource/prompt handler. Notifications, cancellations, and malformed or unroutable requests were invisible to middleware. `FastMCPServerMiddleware` — FastMCP's entry in the SDK's own middleware list — is now the dispatch root: it runs the `on_message`/`on_request`/`on_notification` pass for every message the interior handlers do not dispatch (all notifications including `notifications/cancelled`, `ping`, `logging/setLevel`, unknown methods, and component requests that fail validation before the handler runs). The component methods keep their interior dispatch unchanged, so `on_call_tool` and friends still receive the typed component result and a tool exception still propagates through `on_message`/`on_request` exactly where the built-in error/logging/timing middleware expect it — each hook fires exactly once per message. Multi-round (SEP-2322) calls compose cleanly with this: each round is a complete request→response cycle through the full chain, and an asking round's `call_next` returns the ask as an ordinary `InputRequiredToolResult` value (see the MRTR entry below). All thirteen built-in middleware pass their suites unmodified. See [What middleware sees](https://gofastmcp.com/servers/middleware#what-middleware-sees). - -*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware` root dispatch, `_INTERIOR_METHODS`), `fastmcp_slim/fastmcp/server/middleware/middleware.py` (`MiddlewarePhase`, `mark_interior_dispatched`), `fastmcp_slim/fastmcp/server/server.py` (`_dispatch_component_middleware`), `tests/server/middleware/test_message_visibility.py`. - -### Per-session state re-homed to the connection — Absorbed - -Because `ServerSession` is now per-request, per-session state can no longer live on the session object. The minimum logging level is re-homed to a FastMCP-side map keyed by session id (via `connection.session_id`), and `client_supports_extension` becomes a free function reading `session.client_params.capabilities`. - -*Verify:* `fastmcp_slim/fastmcp/server/low_level.py`, `server/context.py` (`_log_to_server_and_client`). - -### `extensions` capability read from the real field — Absorbed (post-review fix) - -SDK v2 declares `extensions` as a real field on `ClientCapabilities`, so a client sending `ClientCapabilities(extensions={...})` populates the field, not `model_extra`. `client_supports_extension` now reads `caps.extensions` first and falls back to `model_extra` only for legacy-serialized clients. - -*Verify:* commit `96ca0092`, `server/low_level.py` / `server/context.py`. - -### Task protocol and the `_sdk_patches` shim — Absorbed (with an upstream gap) - -The SEP-1686 task CRUD protocol (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`) is entirely FastMCP-owned — the SDK ships no task store. Task detection moves to a params field: `params.task is not None` on `CallToolRequestParams`, with `ttl` from `params.task.ttl`. The four task handlers port to `add_request_handler`. - -The SDK has a real gap here (see [Known Gaps](known-gaps.md) and sdk-feedback #1): it ships the task result types but omits them from the method registries, so a background-task `tools/call` returning a `CreateTaskResult` fails validation. FastMCP installs a registry-widening shim in `_sdk_patches.py` that adds `CreateTaskResult` to the `tools/call` result union and registers the `tasks/*` rows. It is a temporary patch with a self-documented removal trigger. - -Resources and prompts have **no `task` field** on their params in b1, so task-augmented resource reads and prompt gets are not wire-expressible — a documented capability regression, tracked by xfails, not a bug FastMCP fixes. - -This section records the migration's *handling* of the SEP-1686 wire layer as it stood at merge. That layer is not the end state: it is slated for removal and rebuild on the `io.modelcontextprotocol/tasks` extension (SEP-2663) as the `fastmcp-tasks` package. See [Background Tasks (SEP-2663)](background-tasks.md) for the forward plan; the `_sdk_patches.py` shim and the `server/tasks/*` wire handlers described here go away with it, while the Docket execution engine moves into `fastmcp-tasks`. - -*Verify:* `fastmcp_slim/fastmcp/_sdk_patches.py`, `server/tasks/*`. - -### Single SERVER span per request — Absorbed (post-migration fix) - -SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each inbound request already emits a SERVER span. FastMCP emits its own richer SERVER span per request (with `fastmcp.*` and auth/session attributes), so a server with an OTel exporter installed would export **two** SERVER spans per request under different attribute conventions. `LowLevelServer.__init__` now drops the SDK's seeded `OpenTelemetryMiddleware` (matched by type, not position, leaving any other seeded middleware intact) and keeps FastMCP's spans. Inbound W3C trace-context extraction is unaffected — FastMCP's telemetry reads `traceparent` from `_meta` itself, so distributed traces still link client to server. Client-side is not double-counted: the SDK's `ClientSession` emits a low-level `MCP send <method>` CLIENT span that nests *under* FastMCP's high-level client span, a legitimate parent/child hierarchy rather than a duplicate. - -*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`. - -### Telemetry on by default, with a three-way mode setting — Absorbed - -FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. `FASTMCP_TELEMETRY_MODE` (`fastmcp.settings.telemetry_mode`, default `native`) controls how much is active: `native` emits spans and propagates trace context; `propagation_only` emits no FastMCP spans but still extracts the incoming `_meta` context and attaches it, so downstream spans are parented to the calling trace; `off` is a full pass-through that touches neither spans nor context. The setting governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send <method>` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a single block for library authors who own the MCP hierarchy for one operation rather than process-wide; it cannot override `off`. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions. - -`propagation_only` is applied at the seam span, which is where the incoming `_meta` parent context is established for the whole request; suppressing only the deeper `server_span` would leave the per-request SERVER span intact and defeat the mode. - -*Verify:* `fastmcp_slim/fastmcp/settings.py` (`telemetry_mode`); `fastmcp_slim/fastmcp/telemetry.py` (`telemetry_mode`, `get_tracer`, `suppress_fastmcp_telemetry`); `fastmcp_slim/fastmcp/server/telemetry.py` (`_propagation_only_span`, `seam_span`, `get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`; `tests/telemetry/test_interop.py`. - -### Spec-correct error codes via a central translator — Breaking (wire error code) - -Resource-not-found responses from the core `resources/read` handler previously used `-32002`. SEP-2164 (and the SDK's own mcpserver, which maps `ResourceNotFoundError` → `INVALID_PARAMS`) makes this `-32602`. The per-adapter `MCPError(code=..., ...)` literals in `server/mixins/mcp_operations.py` are replaced by a single `fastmcp.exceptions.to_mcp_error()` translator that maps FastMCP's public exceptions to the `mcp_types` code constants (`NotFoundError`/`DisabledError`/`ValidationError` → `INVALID_PARAMS`, else `INTERNAL_ERROR`). Clients that string-matched on the old `-32002` for resource-not-found must switch to `-32602`; the human-readable message ("Resource not found: ...") is unchanged. The opt-in `ErrorHandlingMiddleware`, which has its own documented per-method-prefix code mapping, is intentionally left as-is. - -*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`to_mcp_error`); `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`; `tests/test_exceptions.py`. - -### `Cachable*` response-cache models renamed to `Cacheable*` — Breaking (rename) <!-- codespell:ignore --> - -The response-caching middleware's Pydantic wrapper models — used to serialize cached tool, resource, and prompt results for `ResponseCachingMiddleware` — carried a spelling typo. `CachableToolResult`, `CachableResourceContent`, `CachableResourceResult`, `CachableMessage`, and `CachablePromptResult` are renamed to `CacheableToolResult`, `CacheableResourceContent`, `CacheableResourceResult`, `CacheableMessage`, and `CacheablePromptResult`. None of these classes are re-exported from `fastmcp` or any package `__init__.py`, so the realistic blast radius is limited to code that imported the old names directly from `fastmcp.server.middleware.caching`: - -```python -# Before (now raises ImportError): -# from fastmcp.server.middleware.caching import CachableToolResult - -# After -from fastmcp.server.middleware.caching import CacheableToolResult -``` - -There is deliberately no compatibility alias for the old spelling. - -*Verify:* `fastmcp_slim/fastmcp/server/middleware/caching.py`. - -### Server-side argument completion — New (opt-in feature) - -A FastMCP server can now answer `completion/complete` requests, suggesting values for prompt arguments and resource-template parameters as a user types. Previously a FastMCP *client* could call `complete()` but a FastMCP *server* had no way to respond — the method was unregistered, so it returned `-32601` (method-not-found) on both eras. The new `@mcp.completion` decorator registers a single server-level handler that receives the reference (a `PromptReference` or `ResourceTemplateReference`), the `CompletionArgument` being completed, and the optional `CompletionContext` of already-supplied argument values, and returns candidates — a list of strings, a `Completion` (to carry the `total`/`has_more` pagination hints), or `None`/empty for a reference it does not recognize (which yields an empty completion, not an error). - -```python -from fastmcp import FastMCP -from mcp_types import PromptReference - -mcp = FastMCP("Completion Server") - - -@mcp.prompt -def write_poem(theme: str) -> str: - return f"Write a poem about {theme}" - - -@mcp.completion -def complete(ref, argument, context): - if isinstance(ref, PromptReference) and argument.name == "theme": - options = ["nature", "love", "adventure"] - return [o for o in options if o.startswith(argument.value)] - return None -``` - -The completions capability is declared exactly when a handler exists: `add_completion_handler` registers the low-level `completion/complete` handler, and the SDK derives the capability from that handler's presence — a server with no completion handler does not advertise it. FastMCP does not hand-set the capability. The single-handler shape mirrors the SDK's own `completion/complete` surface and FastMCP's existing client-side `Client.complete()`, and it slots into the `@mcp.tool`/`@mcp.prompt`/`@mcp.resource` decorator lineup as another server-level `@mcp.<verb>` registration rather than inventing a per-argument sub-decorator idiom. It works identically on the handshake and modern (`2026-07-28`) eras, since `completion/complete` is a request/response method that flows on every era. The authoring types — `PromptReference`, `ResourceTemplateReference`, `CompletionArgument`, `CompletionContext`, and `Completion` — are imported from `mcp_types`, not `fastmcp.types`. - -*Verify:* `fastmcp_slim/fastmcp/server/completions.py` (handler type + `normalize_completion`), `fastmcp_slim/fastmcp/server/server.py` (`completion` decorator, `add_completion_handler`), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_complete`), `tests/server/test_completions.py`, `docs/servers/completions.mdx`. - -## Client - -The `fastmcp.Client` public API is largely preserved. The client stays a wrapper around `mcp.ClientSession`; the first-class `mcp.client.Client` is deliberately not adopted. Two client-surface changes are called out below: the connection `mode` default flips to `"auto"`, and `extensions=` / `result_claims=` are newly surfaced. - -### Connection `mode` defaults to `"auto"` — Breaking (behavior) - -`Client(mode=...)` now defaults to `"auto"` instead of `"legacy"`. The client probes `server/discover` and adopts the modern (`2026-07-28`) era when the server responds, denylist-falling-back to the initialize handshake for any server that is not positive evidence of a modern peer. Against a FastMCP server (which serves both eras), an ordinary `Client(url)` now negotiates the modern era by default, where the legacy-only Context push features are unavailable per the per-feature era matrix (see the *Protocol eras* section below) — server-initiated sampling/elicitation/roots, `ping`, session ids, and FastMCP task submission all require the legacy era. The one-line revert is `Client(..., mode="legacy")`, which restores byte-identical pre-v4 negotiation. - -The SSE transport is legacy-only (it cannot carry the sessionless modern era), so a client connecting over SSE negotiates the legacy handshake even under `mode="auto"` — expressed by a `ClientTransport.legacy_only` flag set on `SSETransport`. `MCPConfigTransport` reports `legacy_only` as a property: a multi-server config is legacy-only (each backend is mounted behind a legacy-era proxy), while a single-server config mirrors its one backend transport's era so a modern Streamable HTTP backend stays modern-capable. Two internal library clients that are inherently handshake-based are pinned to legacy so the flip does not break them: the `ProxyClient` backend (which forwards the initialize handshake and server-initiated features) defaults to `mode="legacy"`, and the `inspect` utility (which reads the full `server_info` only the handshake carries) connects legacy. - -```python -from fastmcp import Client - -client = Client("https://example.com/mcp") # now negotiates "auto" -client = Client("https://example.com/mcp", mode="legacy") # opt back into the handshake -``` - -*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`mode` default, `_negotiate` `legacy_only` shortcut), `fastmcp_slim/fastmcp/client/transports/{base,sse,config}.py` (`legacy_only`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`ProxyClient` legacy default), `fastmcp_slim/fastmcp/mcp_config.py` and `fastmcp_slim/fastmcp/utilities/inspect.py` (legacy inner clients), `tests/client/client/test_mode_negotiation.py` (default, clean discover-rejection fallback, legacy-only transport), `tests/test_mcp_config.py` (single- vs multi-server `legacy_only`), `docs/clients/client.mdx`. - -### `extensions=` / `result_claims=` surfaced — New (opt-in feature) - -`fastmcp.Client` now accepts `extensions=` (a sequence of SEP-2133 `ClientExtension` instances) and `result_claims=` (extra `ResultClaim`s keyed by an advertised extension's identifier). Each extension's capability advertisement, result claims, and notification bindings are folded into the underlying `ClientSession` on every transport. User-supplied notification bindings **compose** with FastMCP's internal task-status binding rather than clobbering it: the task binding always leads, and a user extension that binds the same method surfaces a clear duplicate-method error at connect time rather than silently winning. Result claims are wired end-to-end: `call_tool()` / `call_tool_mcp()` pass `allow_claimed=True` and resolve a claimed result through the owning claim's resolver (`ClaimContext`), so a server-emitted claimed shape is finished into an ordinary `CallToolResult` instead of raising `UnexpectedClaimedResult`. Claimed shapes are modern-only, so they are inert on a legacy connection. - -*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`_build_extension_kwargs`, `_resolve_claimed_result`, `new()`), `fastmcp_slim/fastmcp/client/mixins/tools.py` (`call_tool_mcp` claim resolution), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire, end-to-end claim resolution). - -### Protocol helpers delegated to the SDK — Absorbed (internal) - -`fastmcp.Client` carried forked copies of three SDK helpers — `_fold_extensions` (with its `_FoldedExtensions` dataclass), `_evicting_message_handler`, and `_synthesize_discover` — written when the SDK had not yet stabilized them. It now imports the SDK's implementations directly. The forks had already drifted: FastMCP's `_fold_extensions` was missing the SEP-2133 `validate_extension_identifier` check, so a non-reverse-DNS extension identifier that the SDK rejects was silently accepted. Adopting the SDK's version closes that gap. No public surface moves; the SDK returns `None` rather than empty collections for the folded claims and bindings, absorbed at the two call sites in `_build_extension_kwargs`. - -Full composition — `fastmcp.Client` holding an `mcp.Client` and delegating the connection lifecycle to it — remains blocked upstream. `mcp.Client._build_session` hardcodes `ClientSession(...)` with no override hook, but FastMCP's `TransportOptions.session_class` is load-bearing: `ProxyClient` supplies a `_ForwardingClientSession` that skips output-schema validation so a backend's schema bug surfaces at the end client rather than as a proxy error. Separately, `mcp.Client.__aenter__` raises on reentry, while FastMCP's refcounted reentrant context manager is depended on by proxy session reuse. Both would need an upstream `session_factory=` hook (the same shape as the `notification_bindings=` ask that unblocked extension composition) before the lifecycle itself can be delegated. - -*Verify:* `fastmcp_slim/fastmcp/client/client.py` (imports from `mcp.client.client`; no local helper definitions), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.session_class`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_ForwardingClientSession`, `PROXY_TRANSPORT_OPTIONS`). - -### Transports yield 2-tuples — Absorbed - -All SDK transports (`streamable_http_client`, `sse_client`, `stdio_client`) now yield a 2-tuple `(read, write)` instead of exposing a third `get_session_id` element. HTTP configuration flows through a caller-supplied `http_client=`. Only the tuple unpack changed on the FastMCP side. - -*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py`, `transports/sse.py`, `transports/stdio.py`. - -### Float timeouts; `timedelta` still accepted — Absorbed - -The SDK session and call timeouts are now plain floats. FastMCP's public `Client(timeout=...)` still accepts a `timedelta`, a plain float, or an int, normalizing through the existing `normalize_timeout_to_seconds` at the `SessionKwargs` chokepoint: - -```python -from datetime import timedelta - -from fastmcp import Client - -client = Client("my_mcp_server.py", timeout=timedelta(seconds=30)) # still works -client = Client("my_mcp_server.py", timeout=30.0) # also works -``` - -*Verify:* `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.read_timeout_seconds: float | None`), `client/client.py`. - -### Connection settings passed to `connect_session` — Breaking (custom transports) - -`ClientTransport.connect_session` takes a new keyword-only `transport_options: TransportOptions | None`, describing how the connecting client wants its session built: which `ClientSession` class to instantiate, and whether to forward the caller's authorization header upstream. Proxies use it to relay backend results without enforcing their output schema (see [Proxy Servers](https://gofastmcp.com/servers/providers/proxy#tool-results-are-relayed-not-inspected)). - -These settings previously lived on the transport instance, so a transport shared between clients leaked one client's configuration into another — including credential forwarding, which `create_proxy(some_client)` would silently enable on the caller's own client. They now travel with the client that wants them, and `forward_incoming_headers` is no longer a settable transport attribute. - -A client only passes the argument when it wants non-default settings, so an ordinary `Client` is unaffected and transports that don't accept it keep working. A custom `ClientTransport` used as a *proxy backend* must accept and honor it: - -```python -import contextlib - -from fastmcp.client.transports.base import ClientTransport, TransportOptions - -class MyTransport(ClientTransport): - @contextlib.asynccontextmanager - async def connect_session(self, *, transport_options=None, **session_kwargs): - options = transport_options or TransportOptions() - async with options.session_class(read, write, **session_kwargs) as session: - yield session -``` - -A transport that wraps others must pass it along; `MCPConfigTransport` forwards it to both its single-server delegate and its composite server. - -*Verify:* `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions`), the four built-in transports, `transports/config.py`, and `tests/server/providers/proxy/test_proxy_server.py`. - -### `get_session_id` via header sniff — Bridged - -The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx2 response event hook on the client it owns, capturing the `mcp-session-id` response header (httpx2 preserves httpx's `event_hooks` API). The removal trigger is the upstream TODO. - -*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py` (`_capture_session_id`, `get_session_id`). - -### Pagination via `params=` — Absorbed - -The SDK's `cursor=` kwarg on `list_*` is gone; pagination now flows through `params=PaginatedRequestParams(cursor=...)`. FastMCP's public `cursor=` on the `list_*_mcp` methods is preserved and translated internally. - -*Verify:* `fastmcp_slim/fastmcp/client/mixins/{tools,resources,prompts}.py`. - -### OAuth `callback_handler` returns `AuthorizationCodeResult` — Breaking (advanced) - -The one OAuth break: a custom `callback_handler` must return an `AuthorizationCodeResult` (fields `code`, `state`, `iss`) instead of the old `tuple[str, str | None]`. Everything else in the OAuth surface — `OAuthClientProvider` kwargs, `TokenStorage`, `async_auth_flow` — is unchanged. - -*Verify:* `fastmcp_slim/fastmcp/client/auth/oauth.py`. - -### Notification dispatch unwrapped — Absorbed - -The client's notification handling was reworked for the v2 message model. Custom server-to-client notifications (like SEP-1686 `notifications/tasks/status`) are no longer tee'd to a user `message_handler` — the SDK routes them only through `NotificationBinding` (see sdk-feedback #8). FastMCP registers a binding so task-status updates reach the Task registry. - -*Verify:* `fastmcp_slim/fastmcp/client/messages.py`, `client/tasks.py`. - -### `SDKServer` alias — Absorbed (post-review rename) - -The in-memory transport resolves the low-level server per server type. The alias for the SDK's own `MCPServer` was renamed from the misleading `FastMCP1Server` / `FastMCP1x` to `SDKServer`, since it names the SDK v2 server, not a FastMCP 1.x object. - -*Verify:* commit `5c3b82e4`; `client/client.py`, `client/transports/memory.py`, `server/providers/proxy.py`, `cli/run.py`. - -### Proxy request-context stash — Absorbed (post-review fix) - -Proxy forwarding handlers stash the request context so a backend that issues a server-initiated request (list_roots/sampling/elicitation) can relay it back to the proxy's own client. This stash was initially applied only on the tool path; commit `1ac166bd` extended it to proxied resources, templates, and prompts. - -*Verify:* commit `1ac166bd`, `server/providers/proxy.py`. - -### Shared response cache via `KeyValueResponseCacheStore` — New - -The SDK's client response cache (SEP-2549) reads and writes through a pluggable `ResponseCacheStore`; the default is a per-client in-memory LRU. FastMCP adds `KeyValueResponseCacheStore`, an adapter over the same `AsyncKeyValue` key-value abstraction the event store and OAuth proxy already use, so a fleet of clients (e.g. proxy replicas) can share one Redis-backed response cache. Pass it via `CacheConfig(store=...)`; a custom store requires an explicit `partition` (SDK) and `target_id` (FastMCP). Results serialize through a type-tagged envelope validated against an allowlist of cacheable result models — an unknown tag is a cache miss, never an import-by-name — and each adapter owns its own collection so `clear()` never touches another tenant. - -```python -from fastmcp.client.caching import KeyValueResponseCacheStore -from mcp.client.caching import CacheConfig -from key_value.aio.stores.redis import RedisStore - -store = KeyValueResponseCacheStore(storage=RedisStore(url="redis://localhost")) -config = CacheConfig(store=store, partition="tenant-a", target_id="weather-api") -``` - -*Verify:* `fastmcp_slim/fastmcp/client/caching.py`, `tests/client/client/test_kv_response_cache.py`. - -### Machine-to-machine client auth — New (feature) - -`fastmcp.client.auth` gains two browser-free auth providers for the OAuth 2.0 `client_credentials` grant, closing the most common client-auth gap (previously only interactive `OAuth` and static `BearerAuth` were available). `ClientCredentialsOAuthProvider(client_id=..., client_secret=...)` authenticates with a client ID and secret; `PrivateKeyJWTOAuthProvider(client_id=..., assertion_provider=...)` uses an RFC 7523 `private_key_jwt` assertion (workload identity federation or a locally signed JWT via the re-exported `SignedJWTParameters` / `static_assertion_provider` helpers). Both are thin wrappers over the SDK's `mcp.client.auth.extensions.client_credentials` providers and implement `httpx2.Auth`, so they slot into the same `Client(auth=...)` path as every other provider. Like interactive `OAuth`, they take the MCP server URL (the token endpoint is discovered from OAuth metadata) and bind to it lazily — omit `mcp_url` and the transport supplies it. In-memory token storage is the default with no warning, since a lost M2M token is re-acquired in one non-interactive request. - -```python -from fastmcp import Client -from fastmcp.client.auth import ClientCredentialsOAuthProvider - -auth = ClientCredentialsOAuthProvider(client_id="id", client_secret="secret") -async with Client("https://example.com/mcp", auth=auth) as client: - await client.list_tools() -``` - -*Verify:* `fastmcp_slim/fastmcp/client/auth/client_credentials.py`, `fastmcp_slim/fastmcp/client/transports/{http,sse}.py`, `tests/client/auth/test_client_credentials.py`. - -## HTTP - -The maintainer asked whether FastMCP can now delete its custom HTTP app and let the SDK's `Server.streamable_http_app()` handle everything. The answer for this PR is **no** — every override earns its keep. Convergence is a v4 project gated on three upstream additions (see [Feature Program](feature-program.md)). - -### Kept overrides — Absorbed - -Four overrides survive, each for a concrete reason: - -1. **Event-store session scoping.** The SDK hands every per-session transport the *same* `event_store` object, one stream-ID keyspace shared across sessions. FastMCP's `FastMCPStreamableHTTPSessionManager` returns a fresh `SessionScopedEventStore(shared, session_id=…)` per session, so resumability events don't leak across sessions. -2. **Lifespan reconciliation.** The SDK builder enters the bare lowlevel `Server.lifespan` (which yields `{}`). FastMCP drives its own `_lifespan_manager` — ref-counted for mounts, Ctrl-C-shielded, docket-aware. The SDK path silently skips all of it, so FastMCP sets the server lifespan to delegate to `_lifespan_manager` and lets the manager enter it once. -3. **Graceful transport termination.** FastMCP's lifespan `finally` drains the manager's server instances via `transport.terminate()` before task-group cancel, fixing the Uvicorn "returned without completing response" edge (#3025). The SDK just cancels. -4. **User ASGI middleware hook.** The SDK builder hardcodes an empty middleware list and only appends auth. FastMCP's `http_app(middleware=...)` and `RequestContextMiddleware` have nowhere to go in the SDK path. - -*Verify:* `fastmcp_slim/fastmcp/server/http.py`, `server/event_store.py`, `server/mixins/lifespan.py`. - -### DNS-rebinding ownership — Absorbed (security) - -FastMCP owns DNS-rebinding protection through its `HostOriginGuardMiddleware`, which is more expressive than the SDK's and is the documented surface. To avoid two allowlists double-blocking with confusing errors from two layers, FastMCP **always** disables the SDK's layer by passing `TransportSecuritySettings(enable_dns_rebinding_protection=False)` to the manager — both when FastMCP's protection is on (so they don't double-block) and when it's off (so the SDK's default-on flip can't silently re-enable it). - -*Verify:* `fastmcp_slim/fastmcp/server/http.py` (`enable_dns_rebinding_protection=False`, `HostOriginGuardMiddleware`). - -### httpx2 replaces httpx — Breaking (custom client/factory, typing) / Absorbed (everything else) - -SDK v2.0.0b2 replaces `httpx` + `httpx-sse` with [httpx2](https://pypi.org/project/httpx2/) (`>=2.5.0`), a next-generation httpx fork with built-in SSE. httpx2 is a near drop-in fork: the public API (`AsyncClient`, `Auth`, `Request`, `Response`, `Timeout`, `MockTransport`, exception hierarchy, `event_hooks`) matches httpx name-for-name. The SDK duck-types the client you hand it — `streamable_http_client(http_client=...)` and `sse_client(httpx_client_factory=...)` are type-hinted `httpx2.AsyncClient` with no `isinstance` gate — but the objects that cross into the SDK must be httpx2. - -FastMCP now uses **httpx2 exclusively** and no longer depends on `httpx`. Every FastMCP-owned HTTP path moves to httpx2: the client transports (`client/transports/{base,http,sse}.py`), client auth (`client/auth/{oauth,bearer}.py` — `BearerAuth`/`OAuth` subclass `httpx2.Auth`), the client-side exception-group handler (`utilities/exceptions.py`), the proxy's upstream client (`server/providers/proxy.py`), the `MCPConfig` client-auth field (`mcp_config.py`), **and** all the server-side code that the earlier migration pass had left on httpx — the ~15 server auth providers' upstream IdP calls, the OpenAPI provider, `from_openapi`/`from_fastapi`, `version_check`, `resources/types.py`, the SSRF download guard, and the `apps_dev` CLI. `httpx` is dropped from the `mcp` extra entirely (it may still arrive transitively via other libraries, but FastMCP never imports it). The ~170 `httpx_mock` calls across the security-critical server-auth test files are ported to a local httpx2-backed `httpx_mock` fixture (`tests/utilities/httpx2_mock.py`) that preserves the `add_response`/`add_exception`/`get_request(s)` API verbatim, so `pytest-httpx` is dropped too. - -User-visible deltas: - -- **Custom client factory / client.** `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, and `OAuth(httpx_client_factory=...)` factories must now return `httpx2.AsyncClient`; a custom `httpx.Auth` passed as `Client(auth=...)` should become `httpx2.Auth`. httpx2 is a drop-in fork, so the change is an import swap (`import httpx` → `import httpx2`). This is a typing break; at runtime a duck-compatible httpx client still satisfies the SDK, but mixing `httpx.Timeout`/`httpx.Auth` with an httpx2 client is unsupported. -- **OpenAPI client.** `FastMCP.from_openapi(client=...)` and `OpenAPIProvider(client=...)` are now type-hinted `httpx2.AsyncClient`. There is no `isinstance` gate, so an existing `httpx.AsyncClient` still works at runtime via duck-typing this release; the typing nudges you to httpx2. -- **TLS trust store.** httpx2 verifies TLS against the OS trust store via `truststore` (honoring `SSL_CERT_FILE`/`SSL_CERT_DIR` first) instead of the bundled certifi CA set. This now applies to **all** FastMCP HTTP, including server-auth upstream IdP calls — not just the client path. Corporate-CA and certifi-pinned setups may see different trust behavior. -- **Logger renames.** FastMCP HTTP now logs under `httpx2` and `httpcore2.*` (was `httpx`/`httpcore.*`). Anyone filtering FastMCP HTTP logs by logger name must update the names. - -The session-id header hook (below) works unchanged: httpx2 keeps httpx's `event_hooks` API. FastMCP's tool/resource/prompt handlers still map upstream 429/timeout errors to actionable `ToolError`/`ResourceError`; because a user's own tool may raise from either library, `server/server.py` catches both `httpx2` and (if installed) legacy `httpx` `HTTPStatusError`/`TimeoutException` via a defensive `try: import httpx` shim. - -*Verify:* `fastmcp_slim/pyproject.toml` (`mcp` extra lists only `httpx2`); no FastMCP source imports `httpx` except the documented defensive shim in `server/server.py`. - -## Protocol eras - -The SDK v2 serves multiple protocol eras from one server, and FastMCP formally embraces this. - -### Dual-era serving — Absorbed (supersedes "latest only") - -A single FastMCP server now handles clients across the protocol transition: the session-based handshake eras (through 2025-11-25) and the sessionless `2026-07-28` era (capability discovery via `server/discover`) simultaneously. This supersedes FastMCP's earlier "latest protocol only" stance. - -### Per-feature era matrix — Breaking (feature availability by era) - -The push-style Context features that require the server to call back into the client are unavailable on the sessionless `2026-07-28` era, because that era removes server-initiated requests (SEP-2577). The request/response features flow on every era. - -| Context feature | Session-based eras | `2026-07-28` (sessionless) | -| --- | --- | --- | -| `ctx.info` / logging notifications | Supported | Supported | -| Tools, resources, prompts, completions | Supported | Supported | -| `ctx.elicit` (imperative) | Supported | Not on the back-channel — use [elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) | -| `ctx.sample` / `ctx.sample_step` | Not in the API | Not in the API — call an LLM server-side | -| `ctx.list_roots` | Not in the API | Not in the API — take paths as arguments, or use the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) | -| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` is absent from the era's registry | -| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension | - -Tools that rely on `ctx.elicit` continue to work against clients on the session-based eras; on the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling and roots have no era row to speak of — they left the server API entirely (see the Removed entry below). - -Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging *notifications* ride the request's own stream and work on every era, including the modern one. The upgrade guide calls it out explicitly. - -Wire interop across the transition is verified: a 3.4.3 client against a v4 server and a v4 client against a 3.4.3 server are bidirectionally clean across 9 operations over HTTP (WS2). - -*Verify:* `docs/getting-started/upgrading/from-fastmcp-3.mdx` (the published matrix and SDK-warning note), `tests/server/test_protocol_eras.py`. - -### Server-initiated sampling and roots removed from the server API — Breaking - -FastMCP 4 is a modern MCP toolkit, so the capabilities the modern protocol removed are not in its server-authoring API. `Context.sample()`, `Context.sample_step()`, and `Context.list_roots()` are gone, along with the whole `fastmcp/server/sampling/` package (`SamplingTool`, `SampleStep`, `SamplingResult`, the tool loop, structured-result sampling) and the server-side handler arguments `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`. These were previously deprecated-and-era-gated; they are now absent. Calling them raises `AttributeError`; the constructor kwargs raise a `TypeError` naming SEP-2577 and the migration. - -The motivating failure is that the gate had become the default experience. `Client` now defaults to `mode="auto"`, which negotiates `2026-07-28` against a FastMCP server, so an unmodified `ctx.sample()` server failed on an ordinary client connection. Four shipped examples (`examples/sampling/`) were broken by that flip; they are deleted rather than ported, and remain available on `release/3.x`. - -Server-initiated sampling and roots are *requests* — the server sends one and blocks for the answer — which needs a back-channel the sessionless protocol does not have. What the protocol removed is the *pushing*, not the asking: both capabilities remain reachable through the guard pattern, where a tool returns an `InputRequiredResult` whose `input_requests` map carries a `CreateMessageRequest` or a `ListRootsRequest`, the client answers it, and the tool re-runs and reads `ctx.input_responses`. `Client._drive_input_required()` dispatches those to the same `sampling_handler` / `roots` handler a handshake-era server would have pushed to, and `tests/conformance/server.py` exercises both routes. For roots that guard round is the recommended modern path. For generation it is available but usually the wrong tool — each round is a full request-response cycle, so an agentic loop exhausts the round-trip budget — and the recommended migration stays a direct LLM call from the server. - -**What is deliberately kept.** Client-side `Client(sampling_handler=..., roots=...)` and the provider handlers (anthropic/openai/google_genai) stay: a FastMCP client must still answer a legacy server's requests, and removing them would break interop with older servers. `docs/clients/sampling.mdx` and `docs/clients/roots.mdx` stay as real documentation. Logging is untouched — `ctx.log`/`info`/`debug`/`warning`/`error` are notifications that ride the request's own stream and work on every era. - -**Proxy relay.** `ProxyClient`'s default `roots` and `sampling_handler` are client-side handlers that relay a handshake-era backend's requests to the proxy's own front client. They are kept, because a proxy is a client to its backend and falls squarely under the interop guarantee above. They no longer route through the removed `Context` methods: both now call the SDK session directly (`ctx.session.list_roots()` / `ctx.session.create_message()`), an internal path with no public authoring surface. The relay is reachable only when both legs speak the handshake era. - -*Verify:* `fastmcp_slim/fastmcp/server/context.py` (no `sample`/`sample_step`/`list_roots`), `fastmcp_slim/fastmcp/server/server.py` (`_REMOVED_KWARGS`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`default_proxy_roots_handler`, `default_proxy_sampling_handler`), `docs/servers/sampling.mdx` (rewritten in place as the explainer), `tests/server/test_protocol_eras.py` (`test_removed_server_initiated_methods_are_absent`), `tests/server/providers/proxy/test_proxy_client.py` (relay still green). - -### `client.set_logging_level()` era-gated — Breaking (modern era) - -`logging/setLevel` asks a server to remember a level for the rest of the session, and it is absent from the `2026-07-28` method registry because that era has no session to remember it in. It previously surfaced the SDK's opaque "Method not found". `Client.set_logging_level()` now raises a `RuntimeError` naming the era and pointing at level-filtering in the client's `log_handler`; it is unchanged on handshake-era connections. It is never a silent no-op. - -*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`set_logging_level`), `tests/server/test_protocol_eras.py` (`test_set_logging_level_is_era_gated_on_modern`). - -### Push-feature degradation quality — Resolved (was sdk-feedback #10) - -On a `2026-07-28` connection `ctx.elicit` used to surface a bare "Method not found", because it attaches a `related_request_id` and reaches client dispatch before failing. FastMCP now era-gates `ctx.elicit` to raise a clear, era-aware `ToolError` before the wire ("elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test. The sampling half of #10 is moot: `ctx.sample` no longer exists. - -*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gate). - -### Server-level cache hints (SEP-2549) — New (opt-in feature) - -A FastMCP server can emit SEP-2549 freshness hints so a caching client (`fastmcp.Client(cache=...)`) may reuse a response without a wire round-trip. Two constructor params carry it: `FastMCP(cache_ttl=300, cache_scope="public")`, where `cache_ttl` is in seconds and `cache_scope` is `"public"` or `"private"` (default `"private"` when a TTL is set). The hint is uniform by construction — one server-level value applies to every SDK-cacheable method (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, and `server/discover`) with no per-component surface and no aggregation. FastMCP does not hand-set the wire fields: it passes the hint through to the SDK low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on every cacheable result via `apply_cache_hint`, leaving any field a handler set explicitly untouched. `cache_ttl` must be positive, and a `cache_scope` without a `cache_ttl` is rejected at construction (a scope alone does not enable caching, since the client gates on the TTL's presence). Absent both params, no hint is emitted. Honoring is modern-only (the SDK client reads hints only at `2026-07-28`) and opt-in on the client, so a hinted server is inert unless the client passes `cache=`. - -*Verify:* `fastmcp_slim/fastmcp/server/caching.py` (`build_cache_hints`), `fastmcp_slim/fastmcp/server/server.py` (constructor params passed to `LowLevelServer(cache_hints=...)`), `tests/server/test_cache_hints.py` (unit validation + end-to-end interop with `fastmcp.Client(cache=True)`). - -### Elicitation on the modern protocol (SEP-2322), guard form — New (opt-in feature) - -A tool can gather client input across rounds on a `2026-07-28` call by returning an `InputRequiredResult` (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle: the tool re-runs per round and reads the client's answers off two new `Context` properties, `ctx.input_responses` (`None` on the first round) and `ctx.request_state` (the echoed opaque state) — thin passthroughs matching the SDK's mcpserver semantics. This is the modern-era elicitation path the earlier per-feature matrix flagged as "MRTR rewrite pending"; it mirrors the SDK's base guard model exactly (tool re-runs, checks whether answers are present, returns to ask for more), with no FastMCP-invented resolver or annotation layer. For authoring these requests, `InputRequiredResult`, `ElicitRequest`, and `ElicitRequestFormParams` import from `mcp_types`. The `request_state` channel is sealed by the framework, not the author: FastMCP installs the SDK's `RequestStateBoundary` middleware on its low-level server, which seals every outgoing `request_state` and unseals and verifies every inbound echo before a tool runs — so a tool only ever sees plaintext and a tampered, expired, or foreign token is rejected with a frozen wire error. `FastMCP(request_state_security=RequestStateSecurity(keys=[...]))` supplies shared keys for multi-replica deployments; omitted, each process seals under an ephemeral key (correct single-process). Returning this result on a handshake-era (≤ 2025-11-25) connection raises a clear era error naming the mismatch rather than failing as a generic invalid result. The client half (`fastmcp.Client` at `mode="auto"`) drives the loop through its existing elicitation/sampling/roots handlers, capped by `input_required_max_rounds`. - -*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`input_responses`/`request_state` properties), `fastmcp_slim/fastmcp/server/low_level.py` (`RequestStateBoundary` install), `fastmcp_slim/fastmcp/server/server.py` (`request_state_security` param), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_call_tool` input-required passthrough + era gate), `fastmcp_slim/fastmcp/tools/base.py` (`InputRequiredToolResult`), `tests/server/test_mrtr_guards.py`. - -### Proxy era mirroring — New (behavior) - -A proxy is a server on its front and a client on its back, and the two eras have mutually exclusive interaction models on a single session: the handshake era pushes server-initiated requests (sampling/elicitation/roots) that the proxy forwards to its client, while the modern era forbids those and round-trips a guard tool's `InputRequiredResult` as a result instead. A proxy created from a non-Client target with no explicit `mode` now MIRRORS the front connection's negotiated era onto its backend session per request, so the whole chain speaks one era end-to-end — a modern client reaches a modern backend (guard round-trips work), a handshake client reaches a handshake backend (push-forwarding works), and the same proxy serves both without a backend session ever crossing eras. Because the default factory builds a fresh backend client per request and derives its `mode` from the front era at call time, only the metadata-only component caches are shared across eras. An explicit `create_proxy(target, mode=...)` still pins the backend era regardless of the front, overriding mirroring for a backend that only speaks one era; the resulting cross-era feature mismatches surface through the existing era gates. `ProxyInitializeMiddleware` no longer force-calls the handshake-only `client.initialize()` when the backend negotiated the modern era, so an explicit modern pin behind a handshake front no longer crashes on connect. The mirrored era carries through a multi-server `MCPConfig` target as well: that form mounts one proxy per configured server onto a composite router, and `TransportOptions.backend_mode` hands the era down to those mounted legs so every real backend negotiates it, not just the router in front of them. That router is also now sealed under a policy held on the transport rather than a fresh per-router ephemeral key, so a guard tool's `request_state` survives the router being rebuilt between rounds. - -*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_mirror_front_era_mode`, the `_create_client_factory` non-Client branch, the era guard in `ProxyInitializeMiddleware.on_initialize`), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.backend_mode`), `fastmcp_slim/fastmcp/client/transports/config.py` (`MCPConfigTransport.connect_session` / `_create_proxy`), `fastmcp_slim/fastmcp/server/server.py` (`create_proxy` docstring), `tests/server/test_mrtr_guards.py` (`TestProxyEraMirroring`, `TestMultiServerConfigEraMirroring`). - -### Resource and prompt errors survive the modern era — Absorbed (defect fix) - -`_on_call_tool` returns a `ResourceError`-equivalent as an error result, but `_on_read_resource` and `_on_get_prompt` caught only `DisabledError`/`NotFoundError`, so a `ResourceError`, `PromptError`, or an argument-conversion failure on a resource template escaped as a raw handler exception. On the handshake eras that reached the wire as `str(exc)`, which is survivable; on `2026-07-28` the runner masks anything that is not an `MCPError` or `ValidationError` as a generic `"Internal server error"`, so a legitimate client-input error became indistinguishable from a server bug. Both handlers now translate a `FastMCPError` through `to_mcp_error` the way tools already do. Masking is unchanged — `mask_error_details` is still applied inside `read_resource`/`render_prompt`, so these paths leak no more than tools do. - -*Verify:* `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_read_resource`, `_on_get_prompt`), `tests/server/test_protocol_eras.py`. - -### Proxies forward upstream instructions on the modern era — Absorbed (defect fix) - -`ProxyInitializeMiddleware` forwards an upstream server's `instructions` by patching the `InitializeResult`, but `on_initialize` only fires for the handshake era. A modern client negotiates via `server/discover`, which the SDK builds from the low-level server's own `instructions`, so a proxy silently dropped its upstream's instructions for every modern client. `FastMCPProxy` now registers a `server/discover` handler (the same `add_request_handler` hook it already uses for `ping`, and a replacement the SDK explicitly sanctions) that delegates to the SDK's own implementation and fills in only the instructions that would otherwise be lost. The proxy's lazy-connect contract is unchanged: the backend is contacted when a client asks, never at construction. Because era mirroring pins a modern backend to an exact version — and a pinned version adopts a synthesized `DiscoverResult` rather than probing the wire — this read negotiates with `mode="auto"`; instructions are metadata with no back-channel, so they do not need the era consistency mirroring exists to protect. - -*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`FastMCPProxy._setup_proxy_discover_handler`), `tests/server/providers/proxy/test_proxy_server.py` (`TestProxyModernEraInstructions`). - -### Proxy list methods raise `MCPError` on backend failure — Breaking (in-process error type) - -`ProxyProvider`'s four `_list_*` methods caught only `MCPError`, so a failed backend connection escaped as the `RuntimeError` the client wraps it in (or a raw `httpx2.ConnectError`). On the handshake eras that reached the wire as `str(exc)` and named the real failure; on `2026-07-28` it was masked as `"Internal server error"`, leaving a modern client unable to tell a dead backend from a server bug. The list methods now normalize transport failures through `_proxy_upstream_error`, matching `ProxyInitializeMiddleware.on_initialize`. Code calling a proxy's `list_tools()` (and friends) in-process must now catch `MCPError` rather than `RuntimeError`; the over-the-wire error type is unchanged. - -*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_PROXY_TRANSPORT_ERRORS` and the four `_list_*` methods), `tests/server/providers/proxy/test_proxy_server.py` (`TestProxyProviderTransportErrors`). - -### The xfail register — Known gap - -Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](known-gaps.md) page. - -## Security - -FastMCP retains hardening that is not yet upstream and does not remove it during the migration. - -### Retained OAuth / DCR hardening — Absorbed - -FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless of the SDK's validation, which still accepts unsafe `javascript:`/`data:` redirect schemes at the model level (sdk-feedback #4). The streamable-HTTP DNS-rebinding protection above is a second retained security surface. - -*Verify:* recent commits `67527c1f` (block unsafe OAuth redirect schemes), `57a27992` (DNS rebinding), `cccb529f` (DCR redirect URI validation) on `main`. - -### Identity assertion (SEP-990 ID-JAG) — Added (beta) - -`OAuthProxy` (and `OIDCProxy`, which inherits it) accepts an optional `identity_assertion=IdentityAssertion(trusted_issuers=[...])`. When configured, the token endpoint accepts the RFC 7523 `urn:ietf:params:oauth:grant-type:jwt-bearer` grant carrying an enterprise IdP-issued ID-JAG, validates it (signature against the trusted issuer's JWKS, `iss`/`aud`/`exp`, `typ` of `oauth-id-jag+jwt`, mandatory `sub`, signed `client_id`/`resource` binding, and `jti` replay rejection), and mints a short-lived FastMCP access token carrying the asserted subject with no refresh token. Authorization server metadata advertises the `jwt-bearer` grant type and the `urn:ietf:params:oauth:grant-profile:id-jag` profile when enabled. This is server-side only; the client-side wrapper ships separately. See [Identity Assertion](https://gofastmcp.com/servers/auth/oauth-proxy#identity-assertion-sep-990). - -*Verify:* `fastmcp_slim/fastmcp/server/auth/identity_assertion.py`, the `exchange_identity_assertion` and `get_routes` changes in `fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py`, and the jwt-bearer dispatch in `fastmcp_slim/fastmcp/server/auth/auth.py` (`TokenHandler._maybe_handle_id_jag`). - -### Templated resource parameters are path-screened by default — Breaking (behavior) - -Every templated resource now has its extracted parameter values screened for path-traversal (`..` segments), absolute paths, and null bytes **before the handler runs** — on by default, at the server's read chokepoint, covering local and provider-sourced (mounted/proxied) templates alike. Previously these payloads reached handlers raw; a template whose parameter flowed into a filesystem path or upstream URL was exposed unless the author added their own check. A rejected read now surfaces a non-leaky "resource not found" error (`-32602`) and a debug log. - -The check is component-based, matching the SDK's `contains_path_traversal`: only a standalone `..` segment is traversal, so values that merely contain dots (`HEAD~3..HEAD`, `file.tar.gz`) and dotfiles (`.env`) still pass. This can break a template that legitimately accepts `..`-bearing or absolute values — exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](https://gofastmcp.com/servers/resources#path-security). - -*Verify:* `fastmcp_slim/fastmcp/resources/security.py` (`ResourceSecurity`), the screening block in `FastMCP.read_resource` (`fastmcp_slim/fastmcp/server/server.py`), and `tests/resources/test_resource_security.py`. - -## Removed in 4.0 - -Deprecations that warned in 3.x are removed in 4.0. Each entry below is a hard removal — the old surface raises `TypeError` / `AttributeError` rather than warning, unless noted otherwise. - -### Module and class shims - -- **`fastmcp.server.proxy`** (deprecated 3.0) — Breaking. Import proxy classes (`FastMCPProxy`, `ProxyClient`, etc.) from `fastmcp.server.providers.proxy` instead. -- **`fastmcp.server.openapi`** and its submodules (`server`, `components`, `routing`), including the **`FastMCPOpenAPI`** class (deprecated 3.0) — Breaking. Use `FastMCP` with an `OpenAPIProvider` from `fastmcp.server.providers.openapi` instead. -- **`fastmcp.experimental.server.openapi`** and **`fastmcp.experimental.utilities.openapi`** shims (deprecated 2.14) — Breaking. Import from `fastmcp.server.providers.openapi` and `fastmcp.utilities.openapi` respectively. -- **`fastmcp.server.apps`** and **`fastmcp.server.app`** shims (deprecated 3.2) — Breaking. Import from `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) instead. -- **`PromptToolMiddleware`** and **`ResourceToolMiddleware`** (deprecated 3.1) — Breaking. Use the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` instead. The non-deprecated `ToolInjectionMiddleware` base class is retained. -- **`StreamableHttpTransport(sse_read_timeout=...)`** (deprecated no-op) — Breaking. The parameter had no effect under the SDK v2 client; configure timeouts via `read_timeout_seconds` in `session_kwargs` or on the httpx2 client via `httpx_client_factory`. `SSETransport` still accepts `sse_read_timeout`. - -### `FastMCP` server methods and `mount()` kwargs - -The following `FastMCP` methods and parameters, deprecated since 3.0, are removed: - -- `FastMCP.as_proxy(...)` → `create_proxy(...)` (`from fastmcp.server import create_proxy`) -- `FastMCP.import_server(sub)` → `mount(sub)` -- `mount(prefix=...)` → `mount(namespace=...)` -- `mount(as_proxy=...)` — removed; mounts always invoke the child's lifespan and middleware, so the flag was already meaningless. To proxy a server, wrap it with `create_proxy()` before mounting. -- `FastMCP.add_tool_transformation(name, config)` → `add_transform(ToolTransform({name: config}))` -- `FastMCP.remove_tool_transformation(name)` — removed; it was a no-op that only warned (transforms are immutable once added). Use `server.disable(keys=[...])` to hide tools. -- `FastMCP.remove_tool(name)` → `mcp.local_provider.remove_tool(name)` - -The `_REMOVED_KWARGS` constructor shim (which raises helpful `TypeError`s for kwargs removed in 3.0) is retained through 4.0. - -### Tool and component parameters - -- **Tool-level `serializer` parameter** — removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, `TransformedTool.from_tool`, the OpenAPI `OpenAPITool`, and the `mcp_mixin` tool decorator. Return a `ToolResult` from your tool for full control over serialization instead (see [Custom Serialization](https://gofastmcp.com/servers/tools#custom-serialization)). The server-level `tool_serializer` constructor kwarg was already removed in 3.0. -- **Tool `exclude_args` parameter** — removed from the tool decorator and its plumbing (`ParsedFunction.from_function`, `Tool.from_function`, `mcp.tool()`). Use dependency injection with `Depends()` to hide parameters from the tool schema instead. -- **`decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode — removed. Decorators always return the original function with metadata attached; the object-returning machinery is gone. Access component objects through the server (e.g. `await mcp.get_tool("name")`) rather than the decorated function. -- **Component-import compatibility shims** — Breaking. `fastmcp.tools.tool`, `fastmcp.resources.resource`, and `fastmcp.prompts.prompt` no longer exist as modules. Two separate mechanisms kept them alive and both are now gone: the `__getattr__` shims that re-exported `FunctionTool` / `ParsedFunction` / `tool`, `FunctionResource` / `resource`, and `FunctionPrompt` / `prompt`; and the `sys.modules` aliases that pointed each old module name at its renamed `base.py`. Import the component types from the package itself — `from fastmcp.tools import Tool, ToolResult` — and the function-backed classes from their canonical modules (`fastmcp.tools.function_tool`, `fastmcp.resources.function_resource`, `fastmcp.prompts.function_prompt`). -- **`fastmcp.experimental.sampling`** and **`fastmcp.experimental.sampling.handlers`** (2.x-era re-export shims) — Breaking. These aliased the client-side sampling handlers without warning. Import from `fastmcp.client.sampling.handlers.openai` instead. Note this is unrelated to the SEP-2577 removal of *server-initiated* sampling: a FastMCP client still answers a legacy-era server's sampling requests, so `Client(sampling_handler=...)` and the Anthropic / OpenAI / Google GenAI handlers under `fastmcp.client.sampling.handlers` remain fully supported. -- **`fastmcp.server.auth.authorization`** (3.0-era re-export shim) — Breaking. The module was a pass-through sitting between the `fastmcp.server.auth` package and the real implementation in `fastmcp.utilities.authorization`, and FastMCP's own middleware and local-provider decorators imported through it. Everything internal now imports from `fastmcp.utilities.authorization` directly. The documented public path is unchanged: `from fastmcp.server.auth import require_scopes, require_roles, restrict_tag, run_auth_checks, AuthCheck, AuthContext`. Two names the old module also exported — `run_auth_checks_with_shortfall` and `scope_requirements` — are *not* re-exported from `fastmcp.server.auth` and must be imported from `fastmcp.utilities.authorization`. They are middleware plumbing with no documented user-facing use, so they were deliberately not widened onto the auth package's surface; the upgrade guide names the utilities path for them explicitly. -- **`SkillsProvider`** (3.0-era rename alias) — Breaking. Use `SkillsDirectoryProvider` from `fastmcp.server.providers.skills`. The alias was also re-exported from `fastmcp.server.providers`; both are gone. -- **`ctx.elicit()` without `response_type`** (deprecated 3.2, warned through 3.4.4) — Breaking. The parameter is now required, and passing `None` explicitly raises `TypeError`. The empty-object schema it produced was ambiguous under the MCP spec and left some clients (e.g. VS Code) rendering an empty, non-functional form. Pass a type describing the data you expect back; `bool` covers confirmations. This is the server-authoring API only — the *client* elicitation handler still receives `response_type=None` for URL requests and for empty schemas sent by other servers, which is unchanged. - -*Verify:* deletions of `fastmcp_slim/fastmcp/server/proxy.py`, `fastmcp_slim/fastmcp/server/openapi/`, `fastmcp_slim/fastmcp/experimental/server/openapi/`, `fastmcp_slim/fastmcp/experimental/utilities/openapi/`, `fastmcp_slim/fastmcp/server/apps.py`, `fastmcp_slim/fastmcp/server/app.py`; the removed classes in `fastmcp_slim/fastmcp/server/middleware/tool_injection.py`; the removed parameter in `fastmcp_slim/fastmcp/client/transports/http.py`; `fastmcp_slim/fastmcp/server/server.py`; `fastmcp_slim/fastmcp/tools/base.py`, `tools/function_tool.py`, `tools/tool_transform.py`, `tools/function_parsing.py`; `fastmcp_slim/fastmcp/settings.py`, `resources/function_resource.py`, `prompts/function_prompt.py`, and the local-provider decorators; `resources/base.py`, `prompts/base.py`. diff --git a/dev-docs/v4-notes/feature-program.md b/dev-docs/v4-notes/feature-program.md deleted file mode 100644 index 8c7b9f022..000000000 --- a/dev-docs/v4-notes/feature-program.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: Feature Program ---- - -The migration is the foundation. The forward v4 program is a sequence of post-merge PRs that build on it. Several have now merged. Each feature below carries an explicit status: - -- **Shipped** — merged to `main`, with the PR cited. -- **Designed** — the approach is settled and an API sketch exists; implementation has not started. -- **Planned** — the shape is agreed but design details remain open. -- **Not started** — identified as v4 scope, not yet designed. - -Code blocks marked as sketches show the *intended* API and do not resolve against the current tree. - -## Sampling removal - -**Status: Shipped in 4.0.** - -Sampling was the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so it cannot work on modern connections, and `Client`'s flip to `mode="auto"` made a modern connection the default — the era gate had become the default experience rather than an edge case. Background-task sampling was dead under v2 in any event: a worker's back-channel is gone once the submitting request returns, and no relay was ever built (sdk-feedback #9). - -Deprecation and era-gating shipped in #4448. The removal completes the plan: `ctx.sample`, `ctx.sample_step`, `ctx.list_roots`, `server/sampling/` (including `SamplingTool` and structured-result sampling), `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`, and `examples/sampling/` are all gone. The server-authoring API is now the modern protocol's API, with nothing in it that only works against old clients. - -The migration story is honest: there is **no drop-in**. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. For roots, take paths as tool arguments or ask through the guard pattern, whose `input_requests` map still carries a `ListRootsRequest`. - -The client-side provider handlers (Anthropic, OpenAI, Google GenAI) and `Client(sampling_handler=..., roots=...)` are **retained**: a FastMCP client still has to answer a legacy server's requests, and MRTR needs them from the client side. What is removed is the server-side push emitter. `ProxyClient`'s default relay handlers are retained for the same interop reason and now call the SDK session directly. - -## MRTR elicitation - -**Status: Guard form shipped (4.0). Declarative `Resolve` layer designed.** - -Elicitation survives the modern era through multi-round-trip (MRTR). The 2026 wire envelope carries elicitation as a multi-round input-request: a tool returns an `InputRequiredResult` and re-runs per round, each round a complete request→response cycle. Imperative `ctx.elicit` relies on the session back-channel, which is gone on `2026-07-28` foreground calls; on the modern era, elicitation is reachable through MRTR instead. - -The **guard form** of this is shipped in 4.0 (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)): a tool returns an `InputRequiredResult` and reads the client's answers off `ctx.input_responses` / `ctx.request_state`, re-running each round. It mirrors the SDK's base guard model exactly — no FastMCP-invented DX, the framework owns `request_state` sealing, and returning this result on a handshake-era connection produces a clear era error. - -What remains is the declarative `Resolve(...)` layer that sits *on top of* that shipped primitive. It is designed, not built: a new `fastmcp.elicitation` module — `Resolve`, `Elicit`, and `ElicitationResult` — thin wrappers over the SDK's resolver, wired into FastMCP's own tool layer (FastMCP tools do not inherit the SDK's auto-resolver wiring). It would detect `Annotated[_, Resolve(...)]` parameters, build resolver plans, and return the SDK's `InputRequiredResult` instead of the tool body on the first round. - -Imperative `ctx.elicit` is **not** re-plumbed to survive the modern era. It works on the legacy eras through the session back-channel, and on `2026-07-28` foreground calls it is era-gated to raise a clear error (shipped in #4448) pointing at the guard form. The earlier plan to keep imperative `ctx.elicit` alive on modern connections through a background-task relay is dead twice over: the guard model shipped in its place, and the 2025 task machinery the relay depended on is slated for removal (see [Known Gaps](known-gaps.md#the-xfail-register)). - -The intended declarative DX (sketch — the module does not exist yet): - -```python test="skip" -from typing import Annotated - -from pydantic import BaseModel - -from fastmcp import FastMCP, Context -from fastmcp.elicitation import Resolve, Elicit, ElicitationResult - -mcp = FastMCP("shipping") - - -class Address(BaseModel): - street: str - city: str - zip: str - - -async def ask_address(ctx: Context) -> Elicit[Address]: - return Elicit("Where should we ship this order?", Address) - - -@mcp.tool -async def create_shipment( - order_id: str, - address: Annotated[Address, Resolve(ask_address)], # unwrapped; decline -> ToolError -) -> str: - return f"Shipping {order_id} to {address.city}" - - -@mcp.tool -async def maybe_ship( - order_id: str, - address: Annotated[ElicitationResult[Address], Resolve(ask_address)], # full outcome -) -> str: - if address.action != "accept": - return "cancelled" - return f"Shipping {order_id} to {address.data.city}" -``` - -The FastMCP client already dispatches input-requests through its elicitation callback; the remaining declarative work confirms the FastMCP client drives the input-required driver the way the SDK's own client does. - -The divergence between elicitation and sampling on 2026 comes down to one fact: the SDK built the server-side emitter for elicitation (`Elicit`/`Resolve`) and not for sampling. The wire carries all three input-request types and the client dispatches all three; only elicitation can produce one server-side. That is why elicitation survives 4.0 via MRTR and push-sampling does not. - -## Middleware root dispatch - -**Status: Shipped (#4553).** - -The migration already routed `initialize` interception through the SDK's `ServerMiddleware` list via `FastMCPServerMiddleware`. #4553 made that entry the root of middleware dispatch: FastMCP's method-agnostic hooks (`on_message`, `on_request`, `on_notification`) now fire for every inbound message — client cancellations, progress notifications, and requests that fail routing or validation — not only the ones that reach a component handler. The component methods keep running their own chain interior, and a method set plus a dispatch flag keep the two passes disjoint so each hook fires exactly once per message. - -## First-class 2026 client - -**Status: Partly shipped (#4572, #4574); full composition blocked upstream.** - -`fastmcp.Client` now defaults to `mode="auto"` (#4572): it probes `server/discover`, falls back to the classic handshake, and answers multi-round-trip `input_required` requests through its existing handlers. The same PR surfaced `extensions=` and `result_claims=` (SEP-2133). The client also dropped its forked protocol helpers — extension folding, the evicting message handler, discover synthesis — in favor of the SDK's own (#4574). - -The decision here was **compose, not wrap** (D16): rebuild `fastmcp.Client` on the SDK's high-level `mcp.Client` rather than wrapping `mcp.ClientSession`. The parts that compose cleanly have shipped. The rest is **blocked upstream on two counts**. First, `mcp.Client` constructs its `ClientSession` at a single hardcoded site with no injection hook, while FastMCP's `session_class` is load-bearing (`ProxyClient` substitutes a session that skips result validation so a backend's schema violation surfaces at the end client rather than becoming a proxy error) — a `session_factory=` hook on `mcp.Client`, the same shape as the `notification_bindings=` parameter added earlier, would solve this. Second, `mcp.Client.__aenter__` refuses reentry, but FastMCP's client is deliberately reentrant (its refcounted context manager exists to fix a proxy session-reuse deadlock), so the rebuild also needs the SDK client to tolerate reentrant entry. Both must land upstream before the full rebuild is possible; `session_factory=` alone is necessary but not sufficient. - -This workstream also owns the server-side statelessness design holes — `ctx.session_id` / `set_state` round-tripping and stateful-proxy affinity — since they turn on the same "what is a session without a session?" question. See [Statelessness on 2026-07-28](known-gaps.md#statelessness-on-2026-07-28) for the full accounting. - -## Subscriptions, cache hints, extensions, OTel - -**Status: Mixed — cache hints and OTel shipped; subscriptions not started.** - -A cluster of protocol features tracked for v4. Their statuses have diverged: - -- **Cache hints — shipped (#4464).** Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`, SEP-2549) stamps every cacheable result, and the FastMCP client honors hints with an opt-in response cache. -- **OpenTelemetry — shipped (#4481).** Spans are on by default (a no-op without an exporter), with SDK-aligned attributes and a `FASTMCP_TELEMETRY_MODE` setting (`native` / `propagation_only` / `off`). -- **Extensions — client side shipped (#4572).** `Client(extensions=..., result_claims=...)` advertises opt-in client extensions (SEP-2133). The server side is a Designed workstream in its own right (see [FastMCP-native extension API](#fastmcp-native-extension-api)). The cross-era reconciliation of the `extensions` / MCP Apps capability advertisement is still open (the capability is stripped at pre-2026 negotiated versions — sdk-feedback #2). -- **Subscriptions — not started.** A `subscriptions/listen` surface backed by a subscription bus. - -## FastMCP-native extension API - -**Status: Shipped (#4602).** - -MCP extensions (SEP-2133) are optional, capability-negotiated protocol features identified by a reverse-DNS string — `io.modelcontextprotocol/ui` (MCP Apps), `io.modelcontextprotocol/tasks` (SEP-2663). They are a genuinely new abstraction in SDK v2; they did not exist in v1. The SDK exposes them through an `Extension` server class that contributes a capability, additive request methods, and a `tools/call` interceptor, plus a symmetric `ClientExtension` with result claims and notification bindings. - -FastMCP already forwards `ClientExtension` natively (`Client(extensions=...)`, #4572). The **server** side does not use the SDK's `Extension` class at all: MCP Apps predates the abstraction, so FastMCP hand-splices the `ui` capability into `get_capabilities()` on the low-level server and walks tool metadata directly. That worked for one extension, but every new protocol extension currently means bespoke surgery on core. - -The Designed work is a FastMCP-native server extension API — a single registration point (`mcp.add_extension(...)`) that contributes a negotiated capability, request methods, and a `tools/call` interceptor, with access to FastMCP-level constructs the SDK's `Extension` withholds (the component registry, `Context`, auth scope). It is designed against the SEP-2663 tasks extension because tasks exercises the full surface — capability *and* methods *and* interception *and* client claims/notifications — where MCP Apps exercises only a subset. Tasks is the pathfinder; MCP Apps migrates onto the extension API as a fast-follow, deleting the hand-rolled splices, and confirms the design generalizes. The discriminator that keeps the extension API distinct from [middleware](https://gofastmcp.com/servers/middleware): an extension is a *negotiated contract change* the client must understand, where middleware is unilateral server behavior the client never sees. Delete a capability advertisement and nothing about the client changes — that is middleware, not an extension. - -## Background tasks (SEP-2663) - -**Status: Shipped (#4603).** - -Background tasks return to the modern era as `fastmcp-tasks`, an in-repo optional package rebuilt on the `io.modelcontextprotocol/tasks` extension (SEP-2663, Final, merged upstream 2026-05-15). SEP-2663 supersedes SEP-1686 but keeps its polling core: a client that advertises the tasks capability issues an augmented `tools/call`; the server decides whether to run it as a task and returns a `CreateTaskResult` carrying a server-generated task id; the client polls `tasks/get` until terminal and reads the result inlined there. FastMCP's existing SEP-1686 wire layer is removed while the Docket/Redis execution engine underneath moves into `fastmcp-tasks` intact — the spec moved toward what FastMCP already built, so the rebuild is mostly deletion plus a thin wire adapter. `task=True` stays the authoring surface (gated by the `fastmcp[tasks]` extra and an explicit `mcp.add_extension(TasksExtension(...))`, the first consumer of the [extension API](#fastmcp-native-extension-api) above), so a server that already uses tasks needs no code change. Scope for v1 is polling-only and `tools/call`-only. - -The full design — wire delta, the engine/wire split, packaging, client experience, sequencing, risks, and the five resolved decisions — is on the dedicated [Background Tasks (SEP-2663)](background-tasks.md) page. - -## SDK delegation, round two - -**Status: Planned (gated on upstream).** - -The real HTTP simplification is a v4 project, not this PR. FastMCP can collapse its `create_streamable_http_app` onto the SDK's `Server.streamable_http_app()` once upstream adds three things: - -1. per-session event-store scoping, -2. a user-middleware injection hook, -3. a lifespan hook. - -The payoff is not only less code — FastMCP would also inherit the SDK's session-owner credential enforcement, a security gain it lacks today. These are the three upstream feature requests to file (alongside the advisory dossier described in [Known Gaps](known-gaps.md)). Until they land, the four HTTP overrides in the [Change Register](change-register.md#http) stay. - -One latent capability worth surfacing on FastMCP's side: `session_idle_timeout` is accepted by the manager but never set by `create_streamable_http_app` — a one-line plumb if FastMCP wants to expose it. diff --git a/dev-docs/v4-notes/protocol-2026.md b/dev-docs/v4-notes/protocol-2026.md deleted file mode 100644 index fb3ef5428..000000000 --- a/dev-docs/v4-notes/protocol-2026.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: 2026-07-28 Protocol Support ---- - -FastMCP v4 serves the sessionless `2026-07-28` protocol era and the session-based handshake eras from a single server, with per-connection auto-detection. This page catalogs what FastMCP provides for the modern era — both the protocol machinery it inherits from the MCP Python SDK and the capabilities FastMCP implements itself on top of that layer. It is the reference for what a v4 deployment can actually do on the modern protocol today. - -## Identity assertion (SEP-990) - -SEP-990 defines enterprise "on-behalf-of" access: a corporate identity provider (Okta, Microsoft Entra, etc.) issues a signed *ID-JAG* asserting an employee's identity, the employee's agent presents it at the MCP authorization server's token endpoint via the RFC 7523 `jwt-bearer` grant, and receives a short-lived access token — no browser login, no per-user consent screen, and revocation lives at the IdP. - -The protocol layer for this flow — grant parsing, the `exchange_identity_assertion` provider hook, and metadata advertisement — comes from the SDK. The validation and issuance logic that makes the flow actually work is FastMCP's implementation, and enabling it is one parameter on the existing auth providers: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import OAuthProxy, IdentityAssertion - -auth = OAuthProxy( - ..., # existing upstream configuration unchanged - identity_assertion=IdentityAssertion( - trusted_issuers=["https://login.acme-corp.com"], - ), -) -mcp = FastMCP("Internal API", auth=auth) -``` - -Behind that one parameter, FastMCP performs the full SEP-990 §5.1 / RFC 7523 §3 processing: JWKS-based signature verification with automatic OIDC discovery of issuer keys, `typ`/`iss`/`aud`/`sub` validation, temporal checks (`exp`, `iat`, `nbf`, maximum assertion lifetime), enforcement of the assertion's signed `client_id` and `resource` bindings, `jti` replay rejection, scope derivation from the signed assertion (client requests can narrow but never widen), short-lived token issuance with no refresh token, and revocation tracking for the issued tokens. The asserted subject flows into the normal FastMCP auth context, so tools read it through `get_access_token()` like any other identity. See [Identity Assertion](https://gofastmcp.com/servers/auth/oauth-proxy#identity-assertion-sep-990) for the full documentation. - -This slots into FastMCP's existing authorization-server stack — the OAuth proxy's dynamic client registration, the consent flow, and self-issued JWTs — which is what makes a one-parameter enterprise deployment possible. - -## Modern-era capability inventory - -The complete picture of what a FastMCP v4 server and client provide on the `2026-07-28` era: - -| Capability | What FastMCP provides | -| --- | --- | -| **Dual-era serving** | One server answers both `server/discover` (modern, sessionless) and `initialize` (handshake) connections, auto-detected per connection. Any replica behind a plain load balancer can answer a modern request. | -| **Identity assertion (SEP-990)** | Complete server-side implementation, one parameter to enable (above). | -| **Authorization server** | Full AS stack: `OAuthProxy` bridges DCR-expecting MCP clients to non-DCR enterprise IdPs, ~18 built-in providers, consent UI, self-issued JWTs, protected-resource metadata (RFC 9728). | -| **Cache hints (SEP-2549)** | Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`) stamps every cacheable result; the FastMCP client honors hints with an opt-in response cache. | -| **Distributed response caching** | `KeyValueResponseCacheStore` backs the client cache with any key-value store (Redis, memory, filetree), so a fleet of clients or proxy replicas shares cache fills across processes. | -| **Resource path security** | Templated resource parameters are screened for traversal, absolute paths, and null bytes before handlers run — on by default, including provider-sourced and mounted templates. | -| **Client protocol negotiation** | `Client(mode="auto")` — the default as of v4 — probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. Pin `mode="legacy"` to force the handshake. | -| **Elicitation on the modern protocol (SEP-2322)** | Tools request user input via multi-round trips: a tool returns an `InputRequiredResult` and re-runs per round, reading the client's answers off `ctx.input_responses` / `ctx.request_state` (the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle; the framework seals `request_state` on the wire and unseals it before the tool runs, and a shared-key `request_state_security` policy carries state across replicas. On handshake-era connections returning this result produces a clear era error. | -| **Spec-standard errors (SEP-2164)** | Missing-resource reads return `-32602`; push-feature calls on modern connections fail with clear era-specific errors rather than generic method-not-found. | -| **Middleware** | Typed per-method hooks (`on_call_tool`, `on_list_tools`, …) and a suite of built-ins (auth, rate limiting, caching, error handling, logging, timing, and more). | -| **Composition** | `mount()`, providers, proxying, and tool transforms compose servers dynamically at runtime, with lifespans and middleware driven through the SDK session manager. | -| **Pagination** | Declarative `FastMCP(list_page_size=...)` paginates all list operations in the high-level server; the client auto-paginates with cycle detection. | -| **Telemetry** | OpenTelemetry spans on by default (no-op without an exporter), SDK-aligned attributes (`mcp.method.name`, `mcp.protocol.version`, `gen_ai.*`), plus auth and provider-delegation spans; `FASTMCP_TELEMETRY_MODE` selects `native`, `propagation_only` (interop with an outer MCP instrumentation layer), or `off`. | -| **Background tasks (SEP-2663)** | `fastmcp-tasks` implements the `io.modelcontextprotocol/tasks` extension end to end: `mcp.add_extension(TasksExtension())` plus `task=True` runs a tool as a background task, driven by the same Docket engine FastMCP 3 used. A client transparently completes a tasked call; gathering input mid-task uses the same guard pattern as foreground multi-round-trip tools, so a tool is written once and works either way. Modern-protocol only — the `task=True` runtime this replaced (SEP-1686) is gone entirely, not bridged. See [Background Tasks (SEP-2663)](background-tasks.md) for the design and [servers/tasks](https://gofastmcp.com/servers/tasks) for usage. | - -## Still in the program - -Elicitation on the modern protocol is now shipped in its **guard form** — a tool returns an `InputRequiredResult` and re-runs per round to gather user input via multi-round trips (see [Elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol)). The declarative `Resolve(...)` layer over that primitive remains staged, tracked in the [Feature Program](feature-program.md), along with the unified `subscriptions/listen` stream. The [Known Gaps](known-gaps.md) page tracks the upstream dependencies that gate them. diff --git a/dev-docs/v4-notes/stateless-session-state.md b/dev-docs/v4-notes/stateless-session-state.md deleted file mode 100644 index 5b0bea73c..000000000 --- a/dev-docs/v4-notes/stateless-session-state.md +++ /dev/null @@ -1,217 +0,0 @@ -# Stateless session state (2026-07-28) - -> Design spec. Status: building. - -## Problem - -The `2026-07-28` era is stateless by protocol construction: each request builds a -fresh `Connection`, `connection.session_id` is always `None`, and -`connection.state` is a new dict discarded when the request returns. So -`ctx.session_id` mints a throwaway `uuid4` per request and `ctx.set_state` / -`ctx.get_state` **silently never round-trip** — no error, just lost data. A user -who wants cross-call state (a cart, a conversation, accumulated context) has no -safe mechanism, and the failure is invisible. - -The one identifier every modern request carries that is stable and -**non-spoofable** is the authenticated principal — `get_access_token().claims["sub"]`, -or the `(client_id, issuer, subject)` triple. Everything else on the wire is -client-declared and forgeable. - -## The model - -State lives **server-side** in the one `AsyncKeyValue` (py-key-value) store the -server already holds (`session_state_store`). The framework calls `get`/`put`/ -`delete` and **never imposes a TTL** — retention is entirely the store's -(configure it on the store you pass: a Redis TTL, a py-key-value TTL wrapper, -whatever). There is no second store and no framework-owned TTL knob. - -Isolation comes from the **authenticated principal, not from the session id.** -State is keyed by `(principal, session_id)`. A request under principal B keys -into B's own namespace — it can never address A's keys no matter what -`session_id` it passes. The id only organizes sessions *within* a principal. The -handle is a bare `uuid4` string; it is **not sealed** — the principal prefix is -the wall. Sessions are also create-then-validate (below): an id that was never -minted by `create_session` under this principal is rejected outright, not -resolved to an empty session. - -## Two explicit patterns - -A tool opts into exactly one, on purpose. There is deliberately **no** optional -"id if given, else default" parameter — that would silently misroute a call -whose id the agent forgot to pass into the shared per-user bucket, which is the -invisible-degradation failure this whole feature exists to remove. - -### Per-user state — injected - -```python -from fastmcp.server.sessions import UserSession - -@mcp.tool -async def remember(fact: str, session: UserSession) -> str: - await session.set("fact", fact) - return "noted" -``` - -`session: UserSession` is **dependency-injected** (like `ctx: Context`): keyed by -the request's authenticated principal, not present in the input schema, nothing -for the agent to pass. Requires auth — with no principal it raises a clear error. -Use it when one bucket per user is what you want. `UserSession` is only the -injection annotation — the value the handler receives is an ordinary `Session`, -so its `get`/`set`/`delete`/`clear` accessors work as usual. - -### Distinct sessions — an argument - -```python -from fastmcp.server.sessions import SessionId -from fastmcp.server.dependencies import get_session - -@mcp.tool -async def add_to_cart(item: str, session_id: SessionId) -> str: - session = await get_session(session_id) - cart = await session.get("cart", default=[]) - cart.append(item) - await session.set("cart", cart) - return f"{len(cart)} items" -``` - -`session_id: SessionId` is a **required string argument** — it *is* in the schema, -the agent supplies it. `SessionId` is a marker type so the framework -auto-populates the argument's description with the protocol: - -> "Session identifier. Use a tool to create a session, then pass the resulting id -> here to persist state across calls in the same session." - -The tool becomes self-teaching — an agent reads the schema and learns the -create-then-pass contract with no hand-prompting. The description names no -specific tool: composition can rename the lifecycle tool (mounting under a -namespace exposes it as `child_create_session`), so it points at the -*capability* rather than a name that may not exist under that mount. - -The standalone `await get_session(session_id)` resolves the id to a `Session` -keyed by `(principal, session_id)`, **validating** that it was created under this -principal — an unknown or foreign id raises `InvalidSession` rather than opening a -fresh bucket. It is a plain function, not a `Context` method, so it needs no -foreground context and works from a `task=True` tool's worker. Use this pattern -when a user needs more than one session. - -## The `Session` object - -Async accessors over the server store, scoped to one `(principal, session_id)`: - -- `session.id` — the session's id (set for a `session_id`-resolved session; `None` - for an injected `UserSession`, which has no distinct id). -- `await session.get(key, default=None)` -- `await session.set(key, value)` -- `await session.delete(key)` -- `await session.clear()` — empties user state but **keeps the session valid**. -- `await session.end()` — deletes the session (what `end_session` calls). - -A session's state is stored as a **single dict under one key** -(`session:{sha256(principal)}:{session_id}`, and `session:anon:{session_id}` when -unauthenticated — the principal is hashed into a fixed-length, delimiter-safe -segment, never embedded raw). That dict holds user state in a `state` sub-dict -alongside a small `_created` marker, so a created-but-empty session is -distinguishable from a missing one even if the store collapses empty dicts. -`get`/`set`/`delete` read-modify-write the sub-dict and never touch the marker; -`clear` resets the sub-dict but leaves the marker (the session still resolves); -`end` deletes the key. Namespacing user state under `state` is what keeps a user -key named `_created` from colliding with the marker. One key per session means -one TTL per session (the store's), refreshed on write — no key index to maintain, -and `end` is a single delete. (Trade-off: concurrent writes to one session race -on the read-modify-write; session state is small and typically driven serially by -one agent, so this is acceptable — noted, not hidden.) - -## `SessionProvider` - -Session ids are minted by `SessionProvider`, which contributes two tools: - -- `create_session()` → mints an unguessable `uuid4`, **records** the session - under the current principal, and returns the id as a string. -- `end_session(session_id: SessionId)` → validates the id, then deletes the - session so it no longer resolves. - -Register it whenever your tools take a `session_id` — providers are the idiomatic -way to add functionality like this: - -```python -from fastmcp.server.sessions import SessionProvider - -mcp.add_provider(SessionProvider()) -``` - -There is **no enforcement** that a provider is registered, and there was: an -earlier version scanned the tool set at list/resolve time and raised if a -`session_id` tool had no provider. That check had to reason about the whole -composition pipeline — `isinstance` on providers, unwrapping namespaced ones, -tool transforms, session visibility, enabled state — and produced false -positives that broke valid servers (a namespaced provider, a session-disabled -tool). It was deleted. The guarantee never needed it: `get_session` validates -that an id was recorded (create-then-validate), so a server with no provider -simply cannot mint ids, and every `get_session` rejects — a misconfiguration -caught the first time the tools run, not a security hole. - -`SessionProvider` subclasses `Provider`, takes **no store** (uses the server's) -and **no ttl** (the store's). It exists to mint and end owned ids. -`create_session` matters most without auth, where an unguessable id is the only -defense against a caller *guessing* onto another session. - -When an application already mints its own identifiers — conversation ids, workflow -ids — take them as ordinary string arguments rather than `SessionId`, and register -no provider; `SessionId` is specifically the create-then-pass contract backed by -`create_session`. - -## Security - -Keyed by `(principal, session_id)`: - -- **Authenticated → strong isolation.** `principal` is the validated token - subject, unforgeable. B keys into B's namespace; A's data is unreachable no - matter what id B passes. Guessing is pointless; a session id appearing in agent - context or logs is harmless (it is not a capability without the principal). - Caller-chosen ids are safe here. -- **Unauthenticated → single-tenant-safe only.** No principal, so the key is just - the id in a shared namespace: the id becomes a bearer capability, and exposure - in logs/conversation leaks the session. `create_session`'s `uuid4` gives - guess-*resistance*, not isolation. Documented in bold: not a tenant boundary; - without auth, force minted ids and never treat sessions as a wall between - clients. -- **Isolation is auth; the id is organization.** No id scheme substitutes for a - principal, which is why sealing the handle buys nothing load-bearing and is - dropped. -- **Not FastMCP's job:** transport (use TLS), encryption at rest (the store's), a - malicious *authorized* client acting within its rights. - -## Rework plan (from the current prototype) - -The prototype (`sessions.py`, `context.py`, `function_tool.py`, `server.py`) built -a `Scope` enum, a sealed `SessionCodec`, and `ctx.get_state(scope=...)`. Rework to -the above: - -1. **Remove `Scope`** and the `scope=` parameter; revert `ctx.get_state`/ - `set_state` to their original request-scoped behavior. -2. **Remove the `SessionCodec`/sealing** — ids are bare `uuid4`. -3. **`Session` object** with async `get`/`set`/`delete`/`clear` over the server - store, single-dict-per-session key scheme. -4. **`session: UserSession`** injection (principal-keyed; error without auth) — - wire into the same parameter-detection path as `Context`. `UserSession` is the - injection marker; the injected value is a `Session`. -5. **`session_id: SessionId`** marker type: string in the schema, auto-filled - description, standalone `await get_session(id)` resolver that validates the id - (works from a task worker — no foreground context needed). -6. **`SessionProvider(Provider)`** with `create_session` (records the session) / - `end_session` (deletes it), registered explicitly via `add_provider`. No - enforcement that it is present — `get_session`'s validation is the guarantee. -7. Rewrite the tests to cover both patterns, principal isolation, no-auth - behavior, and `end_session`. - -## Docs plan - -Written against the final API once the rework verifies: - -- A concept guide — why stateless removes the session, the two patterns, when to - reach for each. Why before how. -- A security page — the two tiers, "isolation is auth, the id is organization," - the bold no-multitenant-without-auth warning. -- Fully runnable examples for both patterns (pass the doc-import guard, register - in `docs.json`). -- A migration note from the old `ctx.session_id` / `set_state`. diff --git a/docs/apps/architecture.mdx b/docs/apps/architecture.mdx index 727697094..7ecab2aa3 100644 --- a/docs/apps/architecture.mdx +++ b/docs/apps/architecture.mdx @@ -29,9 +29,11 @@ When you mark a tool with `app=True` or `@app.ui()`, FastMCP wires up the metada ### The `app=True` flag -`app` on `@mcp.tool` accepts `True`, an `AppConfig`, or a dict. When you pass `True`, FastMCP explicitly marks the tool as a Prefab UI tool and stamps placeholder UI metadata so the provider can synthesize the correct renderer resource later. When you omit `app`, FastMCP only applies this automatically if the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). +`app` on `@mcp.tool` accepts `True`, an `AppConfig`, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If it qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict. -The tool and renderer are linked through a `resourceUri` field in the metadata. Internally, registration uses the placeholder URI `ui://prefab/renderer.html`; when tools and resources are listed or read, FastMCP rewrites that placeholder to a per-tool URI like `ui://prefab/tool/<hash>/renderer.html` and synthesizes the matching renderer resource on demand. +This expansion also registers the shared Prefab renderer resource (below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`" and the host fetches that resource when it displays the result. + +Type inference works the same way. If the return type is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`. ### FastMCPApp registration @@ -47,13 +49,13 @@ When a Prefab tool runs, its return value — a `PrefabApp` or a bare `Component The entry point is `PrefabApp.to_json()`. It walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata). -FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. For `FastMCPApp` backend tools, that registered name is then wrapped in the deterministic hashed format described below. The resolver also handles `unwrap_result` — a flag telling the renderer to unwrap single-value results from the `{"result": value}` envelope FastMCP uses for schema compliance. +FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` on the wire. The resolver also handles `unwrap_result` — a flag telling the renderer to unwrap single-value results from the `{"result": value}` envelope FastMCP uses for schema compliance. -### Hashed backend tool references +### The `_meta.fastmcp.app` tag -FastMCP still tags app tools with `meta["fastmcp"]["app"]`, but backend routing no longer depends on sending the app name through each tool call. During serialization, FastMCP passes a resolver to `PrefabApp.to_json()`. When the tree contains `CallTool(save_contact)`, the resolver turns it into a deterministic hashed name such as `<hash>_save_contact`, where the hash is derived from the app name and backend tool name. +After `to_json()` produces the tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer. -That hashed name rides along inside `structuredContent` all the way to the renderer. When the renderer calls the backend tool, it sends the hashed tool name in the normal MCP `tools/call` request. The server recognizes that format and routes through the app-tool lookup path described below. +When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms (below). ### ToolResult assembly @@ -61,55 +63,29 @@ The final tool result has two parts: `content` (a list of `TextContent` blocks f ## Tool call routing -A tool has two things that behave very differently. Its **name** is unstable by design — namespace transforms rename it, so `save_contact` becomes `contacts_save_contact` in one composition and something else in another. Its **identity** is a hash of the app name and the registered tool name, written once at registration and never changed. +Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters) before resolving by name. App UI calls need a different path. -A UI is serialized during the entry tool's call, deep inside whatever composition the server happens to have, so it cannot know what its backend tools will be called by the time the payload reaches a host. +### The `get_app_tool` bypass -### Late-bound tool names +Backend tools are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — while the renderer still uses the original name. -The payload leaves the app addressed by identity, and every FastMCP server rewrites those references on the way out to whatever it lists that tool as. Servers unwind innermost-first, so the outermost server rewrites last — and its names are the only ones a client can actually invoke. +`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This walks the provider tree directly, skipping transforms. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app. -Rewriting a name in place would destroy the identity for the next layer up, so the payload carries a name-to-identity map under `_meta.fastmcp.toolNames`. Each layer resolves through the map and updates it. The action objects keep the exact shape `prefab_ui` defines: only the value of `tool` changes, and only ever to another valid tool name. +That's why `CallTool("save_contact")` keeps working when the server is mounted under a namespace. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find it without transforms in the way. -The result is that a renderer receives names that exist in the listing the host is looking at. Under three layers of namespacing the button calls `c_b_a_save`; behind a gateway it calls whatever the gateway lists. No intermediary has to understand a FastMCP-specific convention. - -A reference this server cannot resolve is left alone rather than corrupted. This is what keeps apps working behind [tool search](/servers/transforms/tool-search) and code mode, which replace `tools/list` with a handful of synthetic tools: there is no better name to bind to, so the reference stays identity-addressed and the fallback below carries it. - -### One copy of an app per server - -**An app name must be unique within a server.** Composing the same app twice breaks its UI, and no namespace or mount arrangement makes it work. - -The reason is structural. Identity is derived from the app name and the tool's registered name, and deliberately nothing else — that is what makes it survive renaming. Two copies of one app therefore produce two tools claiming a single identity, and no fact anywhere in the listing says which copy a given button belongs to. The information needed to choose was never recorded. - -FastMCP declines to bind rather than picking a copy, so buttons stop working instead of quietly invoking the wrong tenant's tool. Expect a message naming the cause: - -``` -Ambiguous app tool 'save': 2 components share the identity '10c0803009ff'. -The same app is composed more than once, so this call cannot be routed to a -single tool. -``` - -Give each copy its own app name. Two tenants running the same product want `FastMCPApp("contacts-acme")` and `FastMCPApp("contacts-globex")` — not two instances of `FastMCPApp("contacts")` under different namespaces, since namespaces rename tools and identity is immune to renaming by design. - -### The hashed lookup fallback - -The identity-addressed form `<hash>_<local_name>` remains callable. FastMCP first tries normal tool resolution; if no tool matches and the name has that shape, it calls `get_tool_by_hash(hash, local_name)`, which walks the provider tree directly, skipping transforms. - -When one identity is claimed by more than one tool — which happens when the same app is composed into two branches — the call is refused rather than resolved, since picking either one would silently route into the wrong branch. - -Authorization still applies. The hashed path skips name and visibility transforms, but auth checks still run against the tool's `auth` config before execution. +Authorization still applies. `get_app_tool` bypasses transforms but runs auth checks against the tool's `auth` config before executing. ### Provider delegation -`get_tool_by_hash` is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's hashed lookup. Backend tools are reachable through any depth of composition. +`get_app_tool` is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. Backend tools are reachable through any depth of composition. ## The renderer The Prefab renderer is a self-contained JavaScript application that interprets the JSON component tree and renders it as a React UI. -### Renderer resources +### The shared resource -FastMCP exposes the renderer through per-tool resources such as `ui://prefab/tool/<hash>/renderer.html`, each with MIME type `text/html;profile=mcp-app`. The HTML is bundled inside the `prefab-ui` Python package; `get_renderer_html()` returns it as a string. The resources are synthesized on demand from each tool's UI metadata, so CSP and permissions can differ per tool even though they use the same Prefab renderer. +FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The HTML is bundled inside the `prefab-ui` Python package; `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource. The resource also carries CSP metadata (via `get_renderer_csp()`) declaring the CDN domains the renderer needs. Hosts use this to configure the iframe's Content Security Policy. @@ -117,7 +93,7 @@ The resource also carries CSP metadata (via `get_renderer_csp()`) declaring the The renderer lives in a sandboxed iframe and communicates with the host using `postMessage`. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) spec: -The host pushes the tool result (with `structuredContent`) into the iframe. The renderer parses the component tree, initializes state, and renders the UI. When the user interacts — submitting a form, clicking a button — and the interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards it as a regular MCP `tools/call` request to the server, using the hashed backend name that FastMCP serialized into the action. +The host pushes the tool result (with `structuredContent`) into the iframe. The renderer parses the component tree, initializes state, and renders the UI. When the user interacts — submitting a form, clicking a button — and the interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards it as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing. The response flows back the same way: server → host → iframe via `postMessage`, and the renderer updates state with the result. diff --git a/docs/apps/development.mdx b/docs/apps/development.mdx index f5c683d14..0d3a71ac7 100644 --- a/docs/apps/development.mdx +++ b/docs/apps/development.mdx @@ -54,8 +54,6 @@ fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 --no-reload | MCP Port | `--mcp-port` | `8000` | Port for your MCP server | | Dev Port | `--dev-port` | `8080` | Port for the dev UI | | Auto-Reload | `--reload` / `--no-reload` | On | Watch files and restart the server on changes | -| Host | `--host` | `127.0.0.1` | Interface for both local servers to bind | -| Log Panel | `--log-panel` / `--no-log-panel` | On | Show or hide the log panel in the dev UI | ## Multiple tools diff --git a/docs/apps/fastmcp-app.mdx b/docs/apps/fastmcp-app.mdx index b3facd212..55b3b7ed7 100644 --- a/docs/apps/fastmcp-app.mdx +++ b/docs/apps/fastmcp-app.mdx @@ -89,11 +89,7 @@ A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool — - What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`? - How do you keep it all wired correctly as you compose servers? -`FastMCPApp` owns these concerns. Entry points register as model-visible, backend tools register as UI-only, and hosts act on those declarations to decide what the model sees. - -Composition is handled by never writing the name down. `CallTool` takes a function reference, and FastMCP resolves it when the UI is serialized — to whatever that tool is actually called by then. Mount the server under a namespace and the button calls `notes_add_note`; put a gateway in front and it calls whatever the gateway lists. Since you never wrote a name, renaming cannot break it. [The architecture page](/apps/architecture) covers how that resolution works. - -The one rule that comes with this: **an app name must be unique within a server.** Composing the same app twice breaks its UI — two copies of `FastMCPApp("notes")` are indistinguishable no matter what namespaces you mount them under, so FastMCP declines to bind rather than picking one. Name apps for what they serve: `FastMCPApp("notes-acme")` and `FastMCPApp("notes-globex")`. [The architecture page](/apps/architecture) explains why identity works this way. +`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers. The rest of this page covers each piece in turn. diff --git a/docs/apps/low-level.mdx b/docs/apps/low-level.mdx index 0cd1b0ea1..01eca739e 100644 --- a/docs/apps/low-level.mdx +++ b/docs/apps/low-level.mdx @@ -70,15 +70,11 @@ def my_tool() -> str: The `visibility` field controls where a tool appears: - `["model"]` — visible to the LLM (the default behavior) -- `["app"]` — callable from within the app UI, kept out of the LLM's tool list +- `["app"]` — only callable from within the app UI, hidden from the LLM - `["model", "app"]` — both This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions. -Visibility is a declaration, and on `tools/list` the host does the filtering — the division the MCP Apps specification defines. Every tool is advertised carrying its `visibility` metadata, which is also what lets a proxy or gateway forward it: an intermediary can only route to a tool it can see. - -That division assumes a host stands between the server and the model. Where one doesn't, FastMCP applies the declaration itself. [Tool search](/servers/transforms/tool-search) and code mode reach the model as ordinary tool output rather than as an advertised listing, and their call-tool proxies execute a name the model supplies — nothing downstream can filter either, so app-only tools are excluded from both. The app's own UI still reaches its backends, because a UI calling by identity is not the model. - ```python @mcp.tool( app=AppConfig( @@ -220,7 +216,7 @@ import qrcode from fastmcp import FastMCP from fastmcp.apps import AppConfig, ResourceCSP from fastmcp.tools import ToolResult -from mcp.types import ImageContent +from fastmcp.types import ImageContent mcp = FastMCP("QR Code Server") diff --git a/docs/apps/providers/file-upload.mdx b/docs/apps/providers/file-upload.mdx index f10ef0da7..b9709d946 100644 --- a/docs/apps/providers/file-upload.mdx +++ b/docs/apps/providers/file-upload.mdx @@ -59,24 +59,16 @@ This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sess In **stateless HTTP** mode, each request creates a new session object with a new ID. Files stored during one request (e.g. the UI upload) will be invisible to the next request (e.g. the LLM calling `list_files`). You **must** override `_get_scope_key` to use a stable identifier like a user ID from your auth token. </Warning> -For stateless deployments, override `_get_scope_key` to return a stable identifier. To scope files by authenticated user, read the caller from `get_access_token()`. - -Reject the request when there is no subject to key on. `get_access_token()` returns `None` on an unauthenticated request, and `subject` is optional even on a valid token, since not every verifier populates it. Returning a fallback in either case would put every such caller in one shared bucket, so they would see each other's uploads. +For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user: ```python from fastmcp.apps.file_upload import FileUpload -from fastmcp.server.dependencies import get_access_token class UserScopedUpload(FileUpload): def _get_scope_key(self, ctx): - token = get_access_token() - if token is None or not token.subject: - raise ValueError("File scoping requires an authenticated user with a subject") - return token.subject + return ctx.access_token["sub"] ``` -If your provider carries the user identity in a different claim, read it from `token.claims` and validate it the same way. - For process-wide shared storage (all users see all files): ```python @@ -93,17 +85,10 @@ The default implementation stores files in memory for the lifetime of the server import base64 from fastmcp.apps.file_upload import FileUpload -from fastmcp.server.dependencies import get_access_token class S3Upload(FileUpload): - def _get_scope_key(self, ctx): - token = get_access_token() - if token is None or not token.subject: - raise ValueError("File scoping requires an authenticated user with a subject") - return token.subject - def on_store(self, files, ctx): - user_id = self._get_scope_key(ctx) + user_id = ctx.access_token["sub"] for f in files: s3.put_object( Bucket="uploads", @@ -113,7 +98,7 @@ class S3Upload(FileUpload): return self.on_list(ctx) def on_list(self, ctx): - user_id = self._get_scope_key(ctx) + user_id = ctx.access_token["sub"] objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/") return [ { @@ -127,7 +112,7 @@ class S3Upload(FileUpload): ] def on_read(self, name, ctx): - user_id = self._get_scope_key(ctx) + user_id = ctx.access_token["sub"] obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}") content = obj["Body"].read() return { diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 0f7efeb17..9ee438d53 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,218 +5,6 @@ rss: true tag: NEW --- -<Update label="v3.4.6" description="2026-08-05"> - -**[v3.4.6: Trust, but Proxy](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6)** - -FastMCP 3.4.6 backports trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches. Deployments can now route these requests through a mandated corporate proxy while preserving custom CA certificates; FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request. - -### Fixes 🐞 -* Backport #4412 to 3.x: support trusted SSRF proxies by [@jlowin](https://github.com/jlowin) in [#4755](https://github.com/PrefectHQ/fastmcp/pull/4755) - -### Docs 📚 -* Docs: add v3.4.6 changelog entries by [@jlowin](https://github.com/jlowin) in [#4761](https://github.com/PrefectHQ/fastmcp/pull/4761) - -**Full Changelog**: [v3.4.5...v3.4.6](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v3.4.6) - -</Update> - -<Update label="v4.0.0b1" description="2026-07-28"> - -**[v4.0.0b1: Fourgone Conclusion](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1)** - -FastMCP 4 makes stateful MCP applications work on the sessionless `2026-07-28` protocol while one deployment continues serving handshake-era clients. Tools can ask follow-up questions across requests, preserve authenticated user state, and move long-running work into background tasks without sticky sessions. Protocol extensions and enterprise identity become first-class surfaces, and most FastMCP 3 servers upgrade unchanged even though MCP Python SDK v2 rewrote the engine underneath them. Server-initiated sampling and roots are removed from the server API; the [upgrade guide](/getting-started/upgrading/from-fastmcp-3) covers their replacements. - -### New Features 🎉 -* Migrate to MCP Python SDK v2 by [@jlowin](https://github.com/jlowin) in [#4437](https://github.com/PrefectHQ/fastmcp/pull/4437) -* Teach fastmcp.Client the modern protocol: mode negotiation, MRTR driver, response cache by [@jlowin](https://github.com/jlowin) in [#4450](https://github.com/PrefectHQ/fastmcp/pull/4450) -* Forward-port Hugging Face auth provider by [@jlowin](https://github.com/jlowin) in [#4475](https://github.com/PrefectHQ/fastmcp/pull/4475) -* Add server-side identity assertion (SEP-990 ID-JAG) by [@jlowin](https://github.com/jlowin) in [#4483](https://github.com/PrefectHQ/fastmcp/pull/4483) -* Add guard-mode multi-round-trip tools (SEP-2322) by [@jlowin](https://github.com/jlowin) in [#4544](https://github.com/PrefectHQ/fastmcp/pull/4544) -* Add FastMCP-native server extension API (SEP-2133) by [@jlowin](https://github.com/jlowin) in [#4602](https://github.com/PrefectHQ/fastmcp/pull/4602) -* Add stateless session state (UserSession / SessionId) by [@jlowin](https://github.com/jlowin) in [#4604](https://github.com/PrefectHQ/fastmcp/pull/4604) -* Add background tasks via the io.modelcontextprotocol/tasks extension (SEP-2663) by [@jlowin](https://github.com/jlowin) in [#4603](https://github.com/PrefectHQ/fastmcp/pull/4603) -### Breaking Changes ⚠️ -* Emit one SERVER span per request and adopt spec-correct error codes by [@jlowin](https://github.com/jlowin) in [#4445](https://github.com/PrefectHQ/fastmcp/pull/4445) -* Remove 3.x deprecated module shims and dead parameters by [@jlowin](https://github.com/jlowin) in [#4447](https://github.com/PrefectHQ/fastmcp/pull/4447) -* Remove 3.0-deprecated FastMCP server methods by [@jlowin](https://github.com/jlowin) in [#4451](https://github.com/PrefectHQ/fastmcp/pull/4451) -* Remove 3.x deprecated parameters and object-mode decorators by [@jlowin](https://github.com/jlowin) in [#4453](https://github.com/PrefectHQ/fastmcp/pull/4453) -* Migrate to MCP SDK v2.0.0b2 (httpx2) by [@jlowin](https://github.com/jlowin) in [#4503](https://github.com/PrefectHQ/fastmcp/pull/4503) -* Fix typos by [@szepeviktor](https://github.com/szepeviktor) in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498) -* Stop proxies from validating backend results or mutating shared transports by [@jlowin](https://github.com/jlowin) in [#4552](https://github.com/PrefectHQ/fastmcp/pull/4552) -* Surface resource, prompt, and proxy errors on the modern protocol by [@jlowin](https://github.com/jlowin) in [#4579](https://github.com/PrefectHQ/fastmcp/pull/4579) -* Negotiate the best mutual protocol era by default by [@jlowin](https://github.com/jlowin) in [#4572](https://github.com/PrefectHQ/fastmcp/pull/4572) -* Remove server-initiated sampling and roots from the server API by [@jlowin](https://github.com/jlowin) in [#4648](https://github.com/PrefectHQ/fastmcp/pull/4648) -* Remove 3.x-era compatibility shims by [@jlowin](https://github.com/jlowin) in [#4661](https://github.com/PrefectHQ/fastmcp/pull/4661) -### Enhancements ✨ -* Deprecate ctx.sample and add clear errors for push features on 2026 connections by [@jlowin](https://github.com/jlowin) in [#4448](https://github.com/PrefectHQ/fastmcp/pull/4448) -* Add server-level cache hints (SEP-2549) by [@jlowin](https://github.com/jlowin) in [#4464](https://github.com/PrefectHQ/fastmcp/pull/4464) -* Add KeyValueResponseCacheStore for distributed client response caching by [@jlowin](https://github.com/jlowin) in [#4479](https://github.com/PrefectHQ/fastmcp/pull/4479) -* Test lifespan fires once per process over HTTP by [@jlowin](https://github.com/jlowin) in [#4480](https://github.com/PrefectHQ/fastmcp/pull/4480) -* Add telemetry off-switch and mcp.protocol.version span attribute by [@jlowin](https://github.com/jlowin) in [#4481](https://github.com/PrefectHQ/fastmcp/pull/4481) -* Trace client task management requests by [@jlowin](https://github.com/jlowin) in [#4525](https://github.com/PrefectHQ/fastmcp/pull/4525) -* Stabilize upgraded ty checks by [@jlowin](https://github.com/jlowin) in [#4526](https://github.com/PrefectHQ/fastmcp/pull/4526) -* Improve DescopeProvider scope discovery and well-known URL support by [@gaokevin1](https://github.com/gaokevin1) in [#4489](https://github.com/PrefectHQ/fastmcp/pull/4489) -* Add examples/ to the ty static-analysis gate by [@jlowin](https://github.com/jlowin) in [#4466](https://github.com/PrefectHQ/fastmcp/pull/4466) -* Expose telemetry attributes on span start by [@zzstoatzz](https://github.com/zzstoatzz) in [#4487](https://github.com/PrefectHQ/fastmcp/pull/4487) -* Fix-issue-4284 : Add Auth0MCPProvider for Auth0 Auth for MCP by [@vijaydeepsinha](https://github.com/vijaydeepsinha) in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411) -* Run FastMCP middleware for every inbound message by [@jlowin](https://github.com/jlowin) in [#4553](https://github.com/PrefectHQ/fastmcp/pull/4553) -* Add 'prs welcome' label to waive the PR assignment gate by [@jlowin](https://github.com/jlowin) in [#4557](https://github.com/PrefectHQ/fastmcp/pull/4557) -* Rename martian workflows to marvin by [@jlowin](https://github.com/jlowin) in [#4558](https://github.com/PrefectHQ/fastmcp/pull/4558) -* Bump pinned Claude models to current versions by [@jlowin](https://github.com/jlowin) in [#4561](https://github.com/PrefectHQ/fastmcp/pull/4561) -* Make the unit suite fast: in-process HTTP tests, no real sleeps, parallel Windows CI by [@jlowin](https://github.com/jlowin) in [#4554](https://github.com/PrefectHQ/fastmcp/pull/4554) -* Mirror the frontend's protocol era on a proxy's backend connection by [@jlowin](https://github.com/jlowin) in [#4573](https://github.com/PrefectHQ/fastmcp/pull/4573) -* Drop forked client protocol helpers in favor of the SDK's by [@jlowin](https://github.com/jlowin) in [#4574](https://github.com/PrefectHQ/fastmcp/pull/4574) -* Bring the v4 developer notes up to date with what shipped by [@jlowin](https://github.com/jlowin) in [#4581](https://github.com/PrefectHQ/fastmcp/pull/4581) -* Trim fastmcp.types to FastMCP-unique types by [@jlowin](https://github.com/jlowin) in [#4584](https://github.com/PrefectHQ/fastmcp/pull/4584) -* Let a server answer argument-completion requests by [@jlowin](https://github.com/jlowin) in [#4582](https://github.com/PrefectHQ/fastmcp/pull/4582) -* Add machine-to-machine client authentication by [@jlowin](https://github.com/jlowin) in [#4583](https://github.com/PrefectHQ/fastmcp/pull/4583) -* Expose era-neutral client server metadata by [@zzstoatzz](https://github.com/zzstoatzz) in [#4599](https://github.com/PrefectHQ/fastmcp/pull/4599) -* Support routable transport headers for gateways (SEP-2243) by [@jlowin](https://github.com/jlowin) in [#4622](https://github.com/PrefectHQ/fastmcp/pull/4622) -* Emit scope step-up challenges for incremental authorization (SEP-2350) by [@jlowin](https://github.com/jlowin) in [#4623](https://github.com/PrefectHQ/fastmcp/pull/4623) -* Honor OAuth application_type in DCR (SEP-837) by [@jlowin](https://github.com/jlowin) in [#4621](https://github.com/PrefectHQ/fastmcp/pull/4621) -* Drop stale label-noting instructions from CLAUDE.md by [@jlowin](https://github.com/jlowin) in [#4654](https://github.com/PrefectHQ/fastmcp/pull/4654) -* Add require_roles auth check by [@jlowin](https://github.com/jlowin) in [#4656](https://github.com/PrefectHQ/fastmcp/pull/4656) -* Add `valid_scopes` parameter to OIDC proxy valid scopes by [@Educg550](https://github.com/Educg550) in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660) -* feat: Add telemetry interop mode for FastMCP by [@strawgate](https://github.com/strawgate) in [#4046](https://github.com/PrefectHQ/fastmcp/pull/4046) -* Note that review comment threads should get an acknowledgement by [@jlowin](https://github.com/jlowin) in [#4678](https://github.com/PrefectHQ/fastmcp/pull/4678) -* Soften the review-comment reply guidance by [@jlowin](https://github.com/jlowin) in [#4683](https://github.com/PrefectHQ/fastmcp/pull/4683) -* Resolve review threads on fix, reply on decline by [@jlowin](https://github.com/jlowin) in [#4685](https://github.com/PrefectHQ/fastmcp/pull/4685) -* Move to the stable MCP Python SDK 2.0.0 by [@jlowin](https://github.com/jlowin) in [#4655](https://github.com/PrefectHQ/fastmcp/pull/4655) -### Security 🔒 -* Drive the FastMCP lifespan through the SDK session manager by [@jlowin](https://github.com/jlowin) in [#4446](https://github.com/PrefectHQ/fastmcp/pull/4446) -* Route skill file access through SDK path-security primitives by [@jlowin](https://github.com/jlowin) in [#4449](https://github.com/PrefectHQ/fastmcp/pull/4449) -* Screen templated resource parameters for path traversal by default by [@jlowin](https://github.com/jlowin) in [#4482](https://github.com/PrefectHQ/fastmcp/pull/4482) -* [codex] Add OAuthProxy RFC 9207 issuer responses by [@jlowin](https://github.com/jlowin) in [#4438](https://github.com/PrefectHQ/fastmcp/pull/4438) -* Apply app visibility where no host can by [@jlowin](https://github.com/jlowin) in [#4692](https://github.com/PrefectHQ/fastmcp/pull/4692) -### Fixes 🐞 -* Capture SharedContext for task-enabled Docket servers by [@jlowin](https://github.com/jlowin) in [#4443](https://github.com/PrefectHQ/fastmcp/pull/4443) -* Fix stale mcp.types imports in examples by [@jlowin](https://github.com/jlowin) in [#4452](https://github.com/PrefectHQ/fastmcp/pull/4452) -* Forward-port HTTP host guard compatibility by [@jlowin](https://github.com/jlowin) in [#4474](https://github.com/PrefectHQ/fastmcp/pull/4474) -* Fix Azure scope fallback by [@zzstoatzz](https://github.com/zzstoatzz) in [#4469](https://github.com/PrefectHQ/fastmcp/pull/4469) -* fix(server): omit ScalarElicitationType wrapper title from elicitation schemas by [@syf2211](https://github.com/syf2211) in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502) -* Skip unsupported JWKS keys instead of failing the whole key set (#4515) by [@earfman](https://github.com/earfman) in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517) -* Don't mutate the caller's schema in compress_schema by [@winklemad](https://github.com/winklemad) in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492) -* Forward upstream instructions through create_proxy by [@verdie-g](https://github.com/verdie-g) in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512) -* Serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4523](https://github.com/PrefectHQ/fastmcp/pull/4523) -* Reject positional-only tool parameters by [@jlowin](https://github.com/jlowin) in [#4524](https://github.com/PrefectHQ/fastmcp/pull/4524) -* Clarify PR-reopen flow and fix label-race that broke auto-reopen by [@jlowin](https://github.com/jlowin) in [#4518](https://github.com/PrefectHQ/fastmcp/pull/4518) -* Clean up disconnected task sessions by [@jlowin](https://github.com/jlowin) in [#4519](https://github.com/PrefectHQ/fastmcp/pull/4519) -* Handle expired OAuth client registrations by [@jlowin](https://github.com/jlowin) in [#4520](https://github.com/PrefectHQ/fastmcp/pull/4520) -* Fix OAuth request annotation after httpx2 migration by [@jlowin](https://github.com/jlowin) in [#4534](https://github.com/PrefectHQ/fastmcp/pull/4534) -* Fix docs banner contrast by [@jlowin](https://github.com/jlowin) in [#4522](https://github.com/PrefectHQ/fastmcp/pull/4522) -* Preserve component metadata in response cache by [@jlowin](https://github.com/jlowin) in [#4521](https://github.com/PrefectHQ/fastmcp/pull/4521) -* Clean up task sessions on connection exit by [@jlowin](https://github.com/jlowin) in [#4535](https://github.com/PrefectHQ/fastmcp/pull/4535) -* Include scopes in auth challenges by [@jlowin](https://github.com/jlowin) in [#4527](https://github.com/PrefectHQ/fastmcp/pull/4527) -* Make examples/ actually trigger the ty gate by [@jlowin](https://github.com/jlowin) in [#4541](https://github.com/PrefectHQ/fastmcp/pull/4541) -* Add subject field to AccessToken initialization by [@piaudonn](https://github.com/piaudonn) in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267) -* Restore Mintlify's fixed banner positioning by [@jlowin](https://github.com/jlowin) in [#4542](https://github.com/PrefectHQ/fastmcp/pull/4542) -* Fix #4292: SSRF guard breaks OAuth/JWKS fetches behind a corporate HTTP proxy by [@endofcake](https://github.com/endofcake) in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412) -* Preserve telemetry attributes when a sampler does not forward them by [@jlowin](https://github.com/jlowin) in [#4539](https://github.com/PrefectHQ/fastmcp/pull/4539) -* Speed up the unit test suite, and fix the task-notification race it surfaced by [@jlowin](https://github.com/jlowin) in [#4550](https://github.com/PrefectHQ/fastmcp/pull/4550) -* Fix label triage applying no labels, and make blocked tool calls fail by [@jlowin](https://github.com/jlowin) in [#4555](https://github.com/PrefectHQ/fastmcp/pull/4555) -* Fix AI workflow allowlists being destroyed by tokenization by [@jlowin](https://github.com/jlowin) in [#4560](https://github.com/PrefectHQ/fastmcp/pull/4560) -* Make transformed tool `required` order deterministic by [@Kludex](https://github.com/Kludex) in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564) -* Stop gather() from creating coroutines it may never schedule by [@jlowin](https://github.com/jlowin) in [#4559](https://github.com/PrefectHQ/fastmcp/pull/4559) -* Restore upgraded dependency checks by [@zzstoatzz](https://github.com/zzstoatzz) in [#4576](https://github.com/PrefectHQ/fastmcp/pull/4576) -* Fix skill frontmatter parsing with UTF-8 BOM by [@hxaxd](https://github.com/hxaxd) in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533) -* Fix File helper extension handling by [@VectorPeak](https://github.com/VectorPeak) in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531) -* Fix percent-encoded skill file names unreadable in resources mode by [@jlowin](https://github.com/jlowin) in [#4590](https://github.com/PrefectHQ/fastmcp/pull/4590) -* Fix flaky stdio crash-recovery tests by [@jlowin](https://github.com/jlowin) in [#4594](https://github.com/PrefectHQ/fastmcp/pull/4594) -* Bridge camelCase ToolAnnotations reads by [@zzstoatzz](https://github.com/zzstoatzz) in [#4597](https://github.com/PrefectHQ/fastmcp/pull/4597) -* Preserve raw CallToolResult tool returns by [@LarryHu0217](https://github.com/LarryHu0217) in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587) -* Advertise only supported token endpoint auth methods in OAuthProxy metadata by [@jlowin](https://github.com/jlowin) in [#4608](https://github.com/PrefectHQ/fastmcp/pull/4608) -* Fix OAuth proxy override typing by [@zzstoatzz](https://github.com/zzstoatzz) in [#4612](https://github.com/PrefectHQ/fastmcp/pull/4612) -* Pin burner-redis below the Windows-crashing 0.1.7 release by [@jlowin](https://github.com/jlowin) in [#4618](https://github.com/PrefectHQ/fastmcp/pull/4618) -* fix : canonical mime type mapping from formats to remove inconsistency #4627 by [@Aman071106](https://github.com/Aman071106) in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628) -* fix: accept callable roots handlers by [@ShuyingZhang](https://github.com/ShuyingZhang) in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639) -* Pass the MCP conformance suite's draft and pending scenarios by [@jlowin](https://github.com/jlowin) in [#4650](https://github.com/PrefectHQ/fastmcp/pull/4650) -* Use issuer_url for OAuth issuer identity by [@jlowin](https://github.com/jlowin) in [#4652](https://github.com/PrefectHQ/fastmcp/pull/4652) -* Fix the ty failure blocking upgrade checks on main by [@jlowin](https://github.com/jlowin) in [#4657](https://github.com/PrefectHQ/fastmcp/pull/4657) -* Bind CIMD assertion audience to the advertised token endpoint by [@jlowin](https://github.com/jlowin) in [#4659](https://github.com/PrefectHQ/fastmcp/pull/4659) -* Record effective scopes on the OAuth transaction by [@jlowin](https://github.com/jlowin) in [#4670](https://github.com/PrefectHQ/fastmcp/pull/4670) -* Copy schemas iteratively so deep nesting still compresses by [@jlowin](https://github.com/jlowin) in [#4671](https://github.com/PrefectHQ/fastmcp/pull/4671) -* Fix OpenAPI allOf reference fields by [@hxaxd](https://github.com/hxaxd) in [#4653](https://github.com/PrefectHQ/fastmcp/pull/4653) -* Flatten OpenAPI discriminator subtypes into request bodies by [@jlowin](https://github.com/jlowin) in [#4677](https://github.com/PrefectHQ/fastmcp/pull/4677) -* Let maintenance releases publish without fastmcp-tasks by [@jlowin](https://github.com/jlowin) in [#4676](https://github.com/PrefectHQ/fastmcp/pull/4676) -* Read CLI-scanned MCP config files as UTF-8 explicitly by [@jlowin](https://github.com/jlowin) in [#4690](https://github.com/PrefectHQ/fastmcp/pull/4690) -* Late-bind app tool names so UIs survive composition by [@jlowin](https://github.com/jlowin) in [#4682](https://github.com/PrefectHQ/fastmcp/pull/4682) -### Docs 📚 -* Docs: forward-port v3.4.4 changelog entries by [@jlowin](https://github.com/jlowin) in [#4476](https://github.com/PrefectHQ/fastmcp/pull/4476) -* Document icon theme support by [@jlowin](https://github.com/jlowin) in [#4537](https://github.com/PrefectHQ/fastmcp/pull/4537) -* Add missing 4.0.0 version badge to Path Security docs by [@jlowin](https://github.com/jlowin) in [#4540](https://github.com/PrefectHQ/fastmcp/pull/4540) -* Align server component docs by [@strawgate](https://github.com/strawgate) in [#4260](https://github.com/PrefectHQ/fastmcp/pull/4260) -* Align CLI, deployment, and config docs by [@strawgate](https://github.com/strawgate) in [#4259](https://github.com/PrefectHQ/fastmcp/pull/4259) -* Align client, Apps, and integration docs by [@strawgate](https://github.com/strawgate) in [#4261](https://github.com/PrefectHQ/fastmcp/pull/4261) -* Fix stale MRTR/elicitation framing in client and upgrade docs by [@jlowin](https://github.com/jlowin) in [#4551](https://github.com/PrefectHQ/fastmcp/pull/4551) -* docs: quote pip extras install examples by [@RachGranville](https://github.com/RachGranville) in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568) -* Document Windows CI parallelism and the subprocess_heavy marker by [@jlowin](https://github.com/jlowin) in [#4575](https://github.com/PrefectHQ/fastmcp/pull/4575) -* Document v3->v4 removals and add upgrade-reality tests by [@jlowin](https://github.com/jlowin) in [#4585](https://github.com/PrefectHQ/fastmcp/pull/4585) -* Archive v3 docs and publish v4 as the primary version by [@jlowin](https://github.com/jlowin) in [#4613](https://github.com/PrefectHQ/fastmcp/pull/4613) -* Document targeted v4 prerelease installation by [@zzstoatzz](https://github.com/zzstoatzz) in [#4598](https://github.com/PrefectHQ/fastmcp/pull/4598) -* Fix stale Mac/Windows-vs-Linux OAuth key/storage docs by [@jlowin](https://github.com/jlowin) in [#4617](https://github.com/PrefectHQ/fastmcp/pull/4617) -* v4 docs quality pass: stale task/era claims, broken links, polish by [@jlowin](https://github.com/jlowin) in [#4619](https://github.com/PrefectHQ/fastmcp/pull/4619) -* whats-new: add the argument completion capability by [@jlowin](https://github.com/jlowin) in [#4620](https://github.com/PrefectHQ/fastmcp/pull/4620) -* docs: fix ProxyProvider docstring example calling nonexistent with_namespace() by [@andrew-stelmach-fleet](https://github.com/andrew-stelmach-fleet) in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633) -* Unpublish v4 development notes; prep docs for beta 1 by [@jlowin](https://github.com/jlowin) in [#4644](https://github.com/PrefectHQ/fastmcp/pull/4644) -* Expand the FAQ for the v4 transition by [@jlowin](https://github.com/jlowin) in [#4649](https://github.com/PrefectHQ/fastmcp/pull/4649) -* Document the issuer_url identity change for upgraders by [@jlowin](https://github.com/jlowin) in [#4658](https://github.com/PrefectHQ/fastmcp/pull/4658) -* Cover require_roles in the v4 highlights by [@jlowin](https://github.com/jlowin) in [#4666](https://github.com/PrefectHQ/fastmcp/pull/4666) -* Fix FAQ: sampling/roots/elicitation legacy-mode advice, SessionProvider registration by [@jlowin](https://github.com/jlowin) in [#4672](https://github.com/PrefectHQ/fastmcp/pull/4672) -* Audit v4 docs: fix missing version badges, fill whats-new gaps by [@jlowin](https://github.com/jlowin) in [#4668](https://github.com/PrefectHQ/fastmcp/pull/4668) -* Docs: add v3.4.5 changelog entries to main by [@jlowin](https://github.com/jlowin) in [#4674](https://github.com/PrefectHQ/fastmcp/pull/4674) -* Split the SDK upgrade guides by SDK version by [@jlowin](https://github.com/jlowin) in [#4684](https://github.com/PrefectHQ/fastmcp/pull/4684) -### Dependencies 📦 -* chore(deps): bump mcp from 1.26.0 to 1.27.2 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4514](https://github.com/PrefectHQ/fastmcp/pull/4514) -* chore(deps): bump actions/setup-node from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4546](https://github.com/PrefectHQ/fastmcp/pull/4546) -* Bump actions/upload-artifact from 4 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4640](https://github.com/PrefectHQ/fastmcp/pull/4640) -* Bump actions/setup-python from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4641](https://github.com/PrefectHQ/fastmcp/pull/4641) -* chore(deps): bump mcp from 1.27.2 to 1.28.1 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4614](https://github.com/PrefectHQ/fastmcp/pull/4614) -### Other Changes 🦾 -* Test: HTTP lifespan fires once per process across sessions by [@jlowin](https://github.com/jlowin) in [#4470](https://github.com/PrefectHQ/fastmcp/pull/4470) -## New Contributors -* @syf2211 made their first contribution in [#4502](https://github.com/PrefectHQ/fastmcp/pull/4502) -* @earfman made their first contribution in [#4517](https://github.com/PrefectHQ/fastmcp/pull/4517) -* @winklemad made their first contribution in [#4492](https://github.com/PrefectHQ/fastmcp/pull/4492) -* @verdie-g made their first contribution in [#4512](https://github.com/PrefectHQ/fastmcp/pull/4512) -* @vijaydeepsinha made their first contribution in [#4411](https://github.com/PrefectHQ/fastmcp/pull/4411) -* @piaudonn made their first contribution in [#4267](https://github.com/PrefectHQ/fastmcp/pull/4267) -* @szepeviktor made their first contribution in [#4498](https://github.com/PrefectHQ/fastmcp/pull/4498) -* @endofcake made their first contribution in [#4412](https://github.com/PrefectHQ/fastmcp/pull/4412) -* @Kludex made their first contribution in [#4564](https://github.com/PrefectHQ/fastmcp/pull/4564) -* @RachGranville made their first contribution in [#4568](https://github.com/PrefectHQ/fastmcp/pull/4568) -* @hxaxd made their first contribution in [#4533](https://github.com/PrefectHQ/fastmcp/pull/4533) -* @VectorPeak made their first contribution in [#4531](https://github.com/PrefectHQ/fastmcp/pull/4531) -* @LarryHu0217 made their first contribution in [#4587](https://github.com/PrefectHQ/fastmcp/pull/4587) -* @andrew-stelmach-fleet made their first contribution in [#4633](https://github.com/PrefectHQ/fastmcp/pull/4633) -* @Aman071106 made their first contribution in [#4628](https://github.com/PrefectHQ/fastmcp/pull/4628) -* @ShuyingZhang made their first contribution in [#4639](https://github.com/PrefectHQ/fastmcp/pull/4639) -* @Educg550 made their first contribution in [#4660](https://github.com/PrefectHQ/fastmcp/pull/4660) - -**Full Changelog**: [v3.4.5...v4.0.0b1](https://github.com/PrefectHQ/fastmcp/compare/v3.4.5...v4.0.0b1) - -</Update> - -<Update label="v3.4.5" description="2026-07-27"> - -**[v3.4.5: Key Change](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.5)** - -FastMCP 3.4.5 collects five fixes for the 3.x line, led by `JWTVerifier` no longer rejecting every token when an authorization server publishes an unrecognized key type such as Ed25519. - -### Fixes 🐞 -* Backport #4517 to release/3.x: skip unsupported JWKS keys (#4515) by [@kakiii](https://github.com/kakiii) in [#4631](https://github.com/PrefectHQ/fastmcp/pull/4631) -* Backport #4469 to release/3.x: fix Azure scope fallback by [@jlowin](https://github.com/jlowin) in [#4662](https://github.com/PrefectHQ/fastmcp/pull/4662) -* Backport #4523 to release/3.x: serialize deep object query parameters by [@jlowin](https://github.com/jlowin) in [#4664](https://github.com/PrefectHQ/fastmcp/pull/4664) -* Backport #4564 to release/3.x: make transformed tool required order deterministic by [@jlowin](https://github.com/jlowin) in [#4665](https://github.com/PrefectHQ/fastmcp/pull/4665) -* Backport #4492 to release/3.x: don't mutate the caller's schema in compress_schema by [@jlowin](https://github.com/jlowin) in [#4663](https://github.com/PrefectHQ/fastmcp/pull/4663) - -## New Contributors -* @kakiii made their first contribution in [#4631](https://github.com/PrefectHQ/fastmcp/pull/4631) - -**Full Changelog**: [v3.4.4...v3.4.5](https://github.com/PrefectHQ/fastmcp/compare/v3.4.4...v3.4.5) - -</Update> - <Update label="v3.4.4" description="2026-07-08"> **[v3.4.4: Host in Translation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.4)** @@ -847,7 +635,7 @@ FastMCP 3.2 is the Apps release: your tools can now return interactive UIs — c * Add tag to docs by [@jlowin](https://github.com/jlowin) in [#3382](https://github.com/PrefectHQ/fastmcp/pull/3382) * Add settings and environment variables reference by [@jlowin](https://github.com/jlowin) in [#3384](https://github.com/PrefectHQ/fastmcp/pull/3384) * Add contributing guidelines and update issue/PR templates by [@jlowin](https://github.com/jlowin) in [#3485](https://github.com/PrefectHQ/fastmcp/pull/3485) -* [Documentation] Move stateless_http transport kwarg to http_app as FastMCP constructor… by [@mhallo](https://github.com/mhallo) in [#3510](https://github.com/PrefectHQ/fastmcp/pull/3510) +* [Documentation] Move stateless_http transport kwarg to http_app as FastMCP constructo… by [@mhallo](https://github.com/mhallo) in [#3510](https://github.com/PrefectHQ/fastmcp/pull/3510) * Update security policy by [@jlowin](https://github.com/jlowin) in [#3521](https://github.com/PrefectHQ/fastmcp/pull/3521) * Add release instructions to CLAUDE.md by [@jlowin](https://github.com/jlowin) in [#3583](https://github.com/PrefectHQ/fastmcp/pull/3583) * fix(docs): correct misleading stateless_http header by [@jlowin](https://github.com/jlowin) in [#3622](https://github.com/PrefectHQ/fastmcp/pull/3622) @@ -2179,7 +1967,7 @@ Thank you to our new contributors and everyone who tested preview builds. Your f * Add configurable redirect URI validation for OAuth providers by [@jlowin](https://github.com/jlowin) in [#1582](https://github.com/PrefectHQ/fastmcp/pull/1582) * Remove invalid-argument-type ignore and fix type errors by [@jlowin](https://github.com/jlowin) in [#1588](https://github.com/PrefectHQ/fastmcp/pull/1588) * Remove generate-schema from public CLI by [@jlowin](https://github.com/jlowin) in [#1591](https://github.com/PrefectHQ/fastmcp/pull/1591) -* Skip flaky windows test / multi-client garbage collection by [@jlowin](https://github.com/jlowin) in [#1592](https://github.com/PrefectHQ/fastmcp/pull/1592) +* Skip flaky windows test / mulit-client garbage collection by [@jlowin](https://github.com/jlowin) in [#1592](https://github.com/PrefectHQ/fastmcp/pull/1592) * Add setting to disable logging configuration by [@isra17](https://github.com/isra17) in [#1575](https://github.com/PrefectHQ/fastmcp/pull/1575) * Improve debug logging for nested Servers / Clients by [@strawgate](https://github.com/strawgate) in [#1604](https://github.com/PrefectHQ/fastmcp/pull/1604) * Add GitHub pull request template by [@strawgate](https://github.com/strawgate) in [#1581](https://github.com/PrefectHQ/fastmcp/pull/1581) diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx index 5a0a4314c..71b89e08a 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -23,24 +23,21 @@ fastmcp auth cimd create \ ```json { - "client_id": "https://YOUR-DOMAIN.com/path/to/client.json", + "client_id": "https://your-domain.com/oauth/client.json", "client_name": "My App", "redirect_uris": ["http://localhost:*/callback"], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code"], - "response_types": ["code"] + "token_endpoint_auth_method": "none" } ``` -By default, the generated document includes a placeholder `client_id`. Update it to match the URL where you'll host the document before deploying, or pass `--client-id` when generating the file. +The generated document includes a placeholder `client_id` — update it to match the URL where you'll host the document before deploying. ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Name | `--name` | **Required.** Human-readable client name | -| Redirect URI | `--redirect-uri`, `-r` | **Required.** Allowed redirect URIs (repeatable) | -| Client ID | `--client-id` | URL where this document will be hosted; defaults to a placeholder | +| Redirect URI | `--redirect-uri` | **Required.** Allowed redirect URIs (repeatable) | | Client URI | `--client-uri` | Client's home page URL | | Logo URI | `--logo-uri` | Client's logo URL | | Scope | `--scope` | Space-separated list of scopes | @@ -54,7 +51,6 @@ fastmcp auth cimd create \ --name "My Production App" \ --redirect-uri "http://localhost:*/callback" \ --redirect-uri "https://myapp.example.com/callback" \ - --client-id "https://myapp.example.com/oauth/client.json" \ --client-uri "https://myapp.example.com" \ --scope "read write" \ --output client.json diff --git a/docs/cli/client.mdx b/docs/cli/client.mdx index 7dac8456d..b5ef1d4e8 100644 --- a/docs/cli/client.mdx +++ b/docs/cli/client.mdx @@ -104,28 +104,11 @@ Some tools request additional input during execution through MCP's elicitation m | ------ | ---- | ----------- | | Command | `--command` | Connect via stdio | | Transport | `--transport`, `-t` | Force `http` or `sse` | -| Prompt | `--prompt` | Treat the target as a prompt name instead of a tool/resource | | Input JSON | `--input-json` | Base arguments as JSON (merged with `key=value`) | | JSON | `--json` | Raw JSON output | | Timeout | `--timeout` | Connection timeout in seconds | | Auth | `--auth` | `oauth`, a bearer token, or `none` | -## Reading Resources and Getting Prompts - -`fastmcp call` can also read resources and render prompts. If the target contains `://`, the CLI treats it as a resource URI and calls `read_resource`: - -```bash -fastmcp call server.py resource://docs/readme -fastmcp call server.py file:///tmp/example.txt --json -``` - -To get a prompt, pass `--prompt`; prompt arguments use the same `key=value` and `--input-json` forms as tool calls: - -```bash -fastmcp call server.py summarize --prompt topic=weather -fastmcp call server.py summarize --prompt --input-json '{"topic": "weather"}' -``` - ## Discovering Configured Servers `fastmcp discover` scans your machine for MCP servers configured in editors and tools. It checks: diff --git a/docs/cli/inspecting.mdx b/docs/cli/inspecting.mdx index 4039e45f8..657921357 100644 --- a/docs/cli/inspecting.mdx +++ b/docs/cli/inspecting.mdx @@ -55,11 +55,6 @@ fastmcp inspect server.py --format mcp -o manifest.json | ------ | ---- | ----------- | | Format | `--format`, `-f` | `fastmcp` or `mcp` (required when using `-o`) | | Output File | `--output`, `-o` | Save to file instead of stdout | -| Python | `--python` | Python version to use when running via `uv` | -| Extra Packages | `--with` | Additional packages to install (repeatable) | -| Project | `--project` | Run within a specific uv project directory | -| Requirements | `--with-requirements` | Install from a requirements file | -| Skip Env | `--skip-env` | Do not set up a uv environment | ## Entrypoints diff --git a/docs/cli/install-mcp.mdx b/docs/cli/install-mcp.mdx index 7d015592e..0171b7854 100644 --- a/docs/cli/install-mcp.mdx +++ b/docs/cli/install-mcp.mdx @@ -14,7 +14,7 @@ import { VersionBadge } from '/snippets/version-badge.mdx' ```bash fastmcp install claude-desktop server.py fastmcp install claude-code server.py --with pandas --with matplotlib -fastmcp install cursor server.py --with-editable . +fastmcp install cursor server.py -e . ``` <Warning> @@ -41,13 +41,14 @@ Because MCP clients run servers in isolation, you need to tell the install comma ```bash fastmcp install claude-desktop server.py --with pandas --with "sqlalchemy>=2.0" -fastmcp install cursor server.py --with-editable . --with-requirements requirements.txt +fastmcp install cursor server.py -e . --with-requirements requirements.txt ``` -**`fastmcp.json`** configuration files declare dependencies alongside the server definition. When you install from a config file explicitly, dependencies are picked up automatically: +**`fastmcp.json`** configuration files declare dependencies alongside the server definition. When you install from a config file, dependencies are picked up automatically: ```bash fastmcp install claude-desktop fastmcp.json +fastmcp install claude-desktop # auto-detects fastmcp.json in current directory ``` See [Server Configuration](/deployment/server-configuration) for the full config format. @@ -56,19 +57,15 @@ See [Server Configuration](/deployment/server-configuration) for the full config | Option | Flag | Description | | ------ | ---- | ----------- | -| Server Name | `--name`, `-n` | Custom name for the server | -| Editable Package | `--with-editable` | Install a directory in editable mode | +| Server Name | `--server-name`, `-n` | Custom name for the server | +| Editable Package | `--with-editable`, `-e` | Install a directory in editable mode | | Extra Packages | `--with` | Additional packages (repeatable) | | Environment Variables | `--env` | `KEY=VALUE` pairs (repeatable) | -| Environment File | `--env-file` | Load env vars from a `.env` file | +| Environment File | `--env-file`, `-f` | Load env vars from a `.env` file | | Python | `--python` | Python version (e.g., `3.11`) | | Project | `--project` | Run within a uv project directory | | Requirements | `--with-requirements` | Install from a requirements file | | Config Path | `--config-path` | Custom path to Claude Desktop config directory (`claude-desktop` only) | -| Workspace | `--workspace` | Install to the workspace directory instead of globally (`cursor` only) | -| Copy | `--copy` | Copy the generated output to the clipboard (`mcp-json` and `stdio` only) | - -`goose` installs through a deeplink that runs your server with `uvx`, so it accepts only `--name`, `--with`, and `--python`. Options that depend on a local uv project — `--with-editable`, `--project`, and `--with-requirements` — are unavailable there. Deeplinks also cannot carry environment variables: passing `--env` or `--env-file` exits with an error directing you to `fastmcp install mcp-json`, which generates a config you can add to Goose by hand with the variables included. ## Examples @@ -76,12 +73,12 @@ See [Server Configuration](/deployment/server-configuration) for the full config # Basic install with auto-detected server instance fastmcp install claude-desktop server.py -# Install from fastmcp.json -fastmcp install claude-desktop fastmcp.json +# Install from fastmcp.json with auto-detection +fastmcp install claude-desktop # Explicit entrypoint with dependencies fastmcp install claude-desktop server.py:my_server \ - --name "My Analysis Server" \ + --server-name "My Analysis Server" \ --with pandas # With environment variables diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 9085daaa8..54783bef0 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -23,7 +23,7 @@ fastmcp --help | [`install`](/cli/install-mcp) | Install a server into Claude Code, Claude Desktop, Cursor, Gemini CLI, or Goose | | [`inspect`](/cli/inspecting) | Print a server's tools, resources, and prompts as a summary or JSON report | | [`list`](/cli/client) | List a server's tools (and optionally resources and prompts) | -| [`call`](/cli/client#calling-tools) | Call a tool, read a resource, or get a prompt | +| [`call`](/cli/client#calling-tools) | Call a single tool with arguments | | [`discover`](/cli/client#discovering-configured-servers) | Find MCP servers configured in your editors and tools | | [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas | | [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project | @@ -89,10 +89,10 @@ To skip authentication entirely — useful for local development servers — pas fastmcp call http://localhost:8000/mcp my_tool --auth none ``` -You can also pass a bearer token directly. Give the token value on its own; FastMCP adds the `Bearer` prefix when it builds the `Authorization` header. +You can also pass a bearer token directly: ```bash -fastmcp list http://localhost:8000/mcp --auth "sk-..." +fastmcp list http://localhost:8000/mcp --auth "Bearer sk-..." ``` ## Transport Override diff --git a/docs/cli/running.mdx b/docs/cli/running.mdx index 92cf764ff..b0cad0a0b 100644 --- a/docs/cli/running.mdx +++ b/docs/cli/running.mdx @@ -69,22 +69,19 @@ fastmcp run mcp.json ``` <Warning> -`fastmcp run` completely ignores the `if __name__ == "__main__"` block. Any setup code in that block won't execute. If you need initialization logic to run, use a [factory function](#entrypoints). +`fastmcp run` completely ignores the `if __name__ == "__main__"` block. Any setup code in that block won't execute. If you need initialization logic to run, use a [factory function](/cli/overview#factory-functions). </Warning> ### Options | Option | Flag | Description | | ------ | ---- | ----------- | -| Transport | `--transport`, `-t` | `stdio` (default), `http` / `streamable-http`, or `sse` | +| Transport | `--transport`, `-t` | `stdio` (default), `http`, or `sse` | | Host | `--host` | Bind address for HTTP (default: `127.0.0.1`) | | Port | `--port`, `-p` | Bind port for HTTP (default: `8000`) | -| Path | `--path` | URL path for HTTP (default: `/mcp` for `http`, `/sse` for `sse`) | +| Path | `--path` | URL path for HTTP (default: `/mcp/`) | | Log Level | `--log-level`, `-l` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | | No Banner | `--no-banner` | Suppress the startup banner | -| Stateless | `--stateless` | Run without sessions, for serverless and multi-worker deployments | -| Module Mode | `--module`, `-m` | Run a Python module via `python -m` instead of a file path | -| Skip Source | `--skip-source` | Skip source preparation (use when the source is already prepared) | | Auto-Reload | `--reload` / `--no-reload` | Watch for file changes and restart automatically | | Reload Dirs | `--reload-dir` | Directories to watch (repeatable) | | Skip Env | `--skip-env` | Don't set up a uv environment (use when already in one) | @@ -130,7 +127,7 @@ Auto-reload is on by default — save a file and the MCP server restarts automat ```bash fastmcp dev inspector server.py -fastmcp dev inspector server.py --with-editable . --with pandas +fastmcp dev inspector server.py -e . --with pandas ``` <Tip> @@ -143,7 +140,7 @@ The Inspector connects over **stdio only**. When it launches, you may need to se | Option | Flag | Description | | ------ | ---- | ----------- | -| Editable Package | `--with-editable` | Install a directory in editable mode | +| Editable Package | `--with-editable`, `-e` | Install a directory in editable mode | | Extra Packages | `--with` | Additional packages (repeatable) | | Inspector Version | `--inspector-version` | MCP Inspector version to use | | UI Port | `--ui-port` | Port for the Inspector UI | diff --git a/docs/clients/auth/bearer.mdx b/docs/clients/auth/bearer.mdx index 109f54df6..3385eda3b 100644 --- a/docs/clients/auth/bearer.mdx +++ b/docs/clients/auth/bearer.mdx @@ -37,7 +37,7 @@ async with Client( "https://your-server.fastmcp.app/mcp", auth="<your-token>", ) as client: - await client.list_tools() + await client.ping() ``` You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`: @@ -52,7 +52,7 @@ transport = StreamableHttpTransport( ) async with Client(transport) as client: - await client.list_tools() + await client.ping() ``` ## `BearerAuth` Helper @@ -67,7 +67,7 @@ async with Client( "https://your-server.fastmcp.app/mcp", auth=BearerAuth(token="<your-token>"), ) as client: - await client.list_tools() + await client.ping() ``` ## Custom Headers @@ -84,5 +84,5 @@ async with Client( headers={"X-API-Key": "<your-token>"}, ), ) as client: - await client.list_tools() + await client.ping() ``` diff --git a/docs/clients/auth/cimd.mdx b/docs/clients/auth/cimd.mdx index 5b1a6b934..c1f92d1c4 100644 --- a/docs/clients/auth/cimd.mdx +++ b/docs/clients/auth/cimd.mdx @@ -32,7 +32,7 @@ async with Client( client_metadata_url="https://myapp.example.com/oauth/client.json", ), ) as client: - await client.list_tools() + await client.ping() ``` When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow. diff --git a/docs/clients/auth/client-credentials.mdx b/docs/clients/auth/client-credentials.mdx deleted file mode 100644 index 382a5d8e9..000000000 --- a/docs/clients/auth/client-credentials.mdx +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Machine-to-Machine Authentication -sidebarTitle: Client Credentials -description: Authenticate your FastMCP client to a protected server without a browser. -icon: robot ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="4.0.0" /> - -<Tip> -Machine-to-machine authentication is only relevant for HTTP-based transports. -</Tip> - -When a FastMCP client runs without a human present — a backend service, a scheduled job, a CI pipeline, one MCP server calling another — it cannot complete the browser-based [OAuth](/clients/auth/oauth) flow. Instead it authenticates as itself using the OAuth 2.0 **client credentials** grant: the client presents its own credentials directly to the authorization server, receives an access token, and attaches that token to every request. There is no redirect, no consent screen, and no user. - -FastMCP provides two providers for this, both implementing the `httpx2.Auth` interface so they drop into the same `auth=` parameter as every other client auth option. You pass the **MCP server URL**, not a token endpoint — the token endpoint is discovered from the server's OAuth metadata, exactly as the interactive `OAuth` helper does. As with `OAuth`, you can omit the URL entirely and let the transport supply it. - -## Client ID and Secret - -The common case is a pre-registered client with an ID and a secret. Use `ClientCredentialsOAuthProvider` and pass it to the `auth` parameter of your `Client` or transport: - -```python {2, 4-8, 10} -from fastmcp import Client -from fastmcp.client.auth import ClientCredentialsOAuthProvider - -auth = ClientCredentialsOAuthProvider( - client_id="my-client-id", - client_secret="my-client-secret", - scopes=["read", "write"], -) - -async with Client("https://example.com/mcp", auth=auth) as client: - await client.list_tools() -``` - -The provider discovers the authorization server, exchanges the credentials for an access token, and caches the token in memory for the life of the client. When the token expires it is re-acquired automatically on the next request. Because re-acquiring a token is a single non-interactive request, tokens are held in memory by default with no warning — unlike the interactive `OAuth` flow, losing the cache on restart costs nothing. - -### `ClientCredentialsOAuthProvider` Parameters - -- **`mcp_url`** (`str`, optional): Full URL to the MCP endpoint. Omit it when passing the provider to `Client(auth=...)` — the transport supplies the URL automatically. -- **`client_id`** (`str`, required): The pre-registered OAuth client ID. -- **`client_secret`** (`str`, required): The OAuth client secret. -- **`scopes`** (`str | list[str]`, optional): Scopes to request, as a space-separated string or a list. -- **`token_endpoint_auth_method`** (`"client_secret_basic" | "client_secret_post"`, optional): How the credentials are presented to the token endpoint. Defaults to `"client_secret_basic"` (an HTTP Basic `Authorization` header); use `"client_secret_post"` to send them in the request body instead. -- **`token_storage`** (`AsyncKeyValue`, optional): A key-value store for the acquired token. Defaults to in-memory storage. - -## Private Key JWT - -Some authorization servers require the client to prove its identity with a signed JWT assertion (RFC 7523 `private_key_jwt`) instead of a shared secret. This is common with workload identity federation, where the assertion comes from a cloud identity provider. Use `PrivateKeyJWTOAuthProvider` and supply an `assertion_provider` — an async callback that receives the authorization server's issuer identifier (the required JWT audience) and returns the assertion. - -For a locally signed assertion, build the callback with `SignedJWTParameters`: - -```python {4-7, 9, 11-15, 17-20, 22} -from pathlib import Path - -from fastmcp import Client -from fastmcp.client.auth import ( - PrivateKeyJWTOAuthProvider, - SignedJWTParameters, -) - -private_key_pem = Path("client-signing-key.pem").read_text() - -jwt_params = SignedJWTParameters( - issuer="my-client-id", - subject="my-client-id", - signing_key=private_key_pem, -) - -auth = PrivateKeyJWTOAuthProvider( - client_id="my-client-id", - assertion_provider=jwt_params.create_assertion_provider(), -) - -async with Client("https://example.com/mcp", auth=auth) as client: - await client.list_tools() -``` - -If you already have a JWT from an identity provider, wrap it with `static_assertion_provider`, or pass your own `async def provider(audience: str) -> str` callback to fetch one on demand. - -### `PrivateKeyJWTOAuthProvider` Parameters - -- **`mcp_url`** (`str`, optional): Full URL to the MCP endpoint. Omit it when passing the provider to `Client(auth=...)`. -- **`client_id`** (`str`, required): The OAuth client ID. -- **`assertion_provider`** (`Callable[[str], Awaitable[str]]`, required): Async callback that receives the authorization server's issuer identifier and returns a signed JWT assertion. -- **`scopes`** (`str | list[str]`, optional): Scopes to request, as a space-separated string or a list. -- **`token_storage`** (`AsyncKeyValue`, optional): A key-value store for the acquired token. Defaults to in-memory storage. diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 21236406d..3a237ef00 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -29,7 +29,7 @@ from fastmcp import Client # Uses default OAuth settings async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client: - await client.list_tools() + await client.ping() ``` @@ -44,7 +44,7 @@ from fastmcp.client.auth import OAuth oauth = OAuth(scopes=["user"]) async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: - await client.list_tools() + await client.ping() ``` <Note> @@ -125,7 +125,7 @@ encrypted_storage = FernetEncryptionWrapper( oauth = OAuth(token_storage=encrypted_storage) async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: - await client.list_tools() + await client.ping() ``` You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/strawgate/py-key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption. @@ -150,7 +150,7 @@ async with Client( client_metadata_url="https://myapp.example.com/oauth/client.json", ), ) as client: - await client.list_tools() + await client.ping() ``` See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents. @@ -172,7 +172,7 @@ async with Client( client_secret="my-client-secret", ), ) as client: - await client.list_tools() + await client.ping() ``` Public clients that rely on PKCE for security can omit `client_secret`: diff --git a/docs/v3/clients/cli.mdx b/docs/clients/cli.mdx similarity index 100% rename from docs/v3/clients/cli.mdx rename to docs/clients/cli.mdx diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index fb910777f..a4141b71d 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -37,6 +37,9 @@ client = Client("my_mcp_server.py") async def main(): async with client: + # Basic server interaction + await client.ping() + # List available operations tools = await client.list_tools() resources = await client.list_resources() @@ -64,21 +67,16 @@ server = FastMCP("TestServer") client = Client(server) # In-memory, no network or subprocess ``` -**STDIO transport** launches a server as a subprocess and communicates through stdin/stdout pipes. This is the standard mechanism used by desktop clients like Claude Desktop. By default, the subprocess receives the MCP SDK's default environment; pass an explicit transport when you need to add environment variables, set a working directory, or control process reuse. +**STDIO transport** launches a server as a subprocess and communicates through stdin/stdout pipes. This is the standard mechanism used by desktop clients like Claude Desktop. The subprocess runs in an isolated environment, so you must explicitly pass any environment variables the server needs. ```python from fastmcp import Client -from fastmcp.client.transports import PythonStdioTransport # Simple inference from file path client = Client("my_server.py") # With explicit environment configuration -transport = PythonStdioTransport( - "my_server.py", - env={"API_KEY": "secret"}, -) -client = Client(transport) +client = Client("my_server.py", env={"API_KEY": "secret"}) ``` **HTTP transport** connects to servers running as web services. Use this for production deployments where the server runs independently and manages its own lifecycle. @@ -123,7 +121,7 @@ async with client: ## Connection Lifecycle -The client uses context managers for connection management. When you enter the context, the client establishes a connection and negotiates the protocol era with the server. Metadata returned by either legacy initialization or modern discovery is exposed through the same client properties. +The client uses context managers for connection management. When you enter the context, the client establishes a connection and performs an MCP initialization handshake with the server. This handshake exchanges capabilities, server metadata, and instructions. ```python from fastmcp import Client, FastMCP @@ -136,20 +134,18 @@ def greet(name: str) -> str: return f"Hello, {name}!" async with Client(mcp) as client: - # Protocol negotiation already happened automatically - assert client.server_info is not None - assert client.server_capabilities is not None - print(f"Server: {client.server_info.name}") - print(f"Instructions: {client.instructions}") - print(f"Capabilities: {client.server_capabilities.tools}") + # Initialization already happened automatically + print(f"Server: {client.initialize_result.server_info.name}") + print(f"Instructions: {client.initialize_result.instructions}") + print(f"Capabilities: {client.initialize_result.capabilities.tools}") ``` -For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually. `initialize()` is a handshake-era operation, so pin the connection with `mode="legacy"`: the modern protocol has no `initialize` round trip, and calling it on a modern connection raises. +For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually: ```python from fastmcp import Client -client = Client("my_mcp_server.py", auto_initialize=False, mode="legacy") +client = Client("my_mcp_server.py", auto_initialize=False) async with client: # Connection established, but not initialized yet @@ -170,26 +166,20 @@ async with client: MCP has two protocol eras: the original *legacy* era, which begins every connection with an `initialize` handshake, and the *modern* era (protocol version `2026-07-28` and later), which a client discovers by probing the server's `server/discover` endpoint. The `mode` parameter controls which era the client negotiates when it connects. -By default, `mode="auto"`. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes the default safe against a mixed fleet of legacy and modern servers. +By default, `mode="legacy"`. This runs the initialize handshake and behaves identically to earlier FastMCP versions, so existing code connecting to any server keeps working unchanged. ```python from fastmcp import Client -# Negotiate the newest era the server supports (the default) -client = Client("https://example.com/mcp", mode="auto") -``` - -Set `mode="legacy"` to force the initialize handshake. This behaves identically to earlier FastMCP versions and is the opt-out if a server misbehaves under discovery or you need the legacy `initialize` result object. - -```python +# Legacy handshake (the default) client = Client("https://example.com/mcp", mode="legacy") ``` -Legacy mode is also what carries the *pushed* form of a server's requests. The handshake opens a persistent back-channel down which a server can send a sampling, roots, or elicitation request mid-call, and the modern era removed it. Your handlers are unaffected by that: a [sampling](/clients/sampling), [roots](/clients/roots), or [elicitation](/clients/elicitation) handler you register answers a modern server's [input-required rounds](/clients/elicitation#input-required-rounds) from the same registration. Pin `mode="legacy"` when you connect to a server that pushes, or when your code calls `client.ping()` or `transport.get_session_id()`, which need the session the modern era does not open. +Set `mode="auto"` to negotiate the newest era the server supports. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes `"auto"` safe to use against a mixed fleet of legacy and modern servers. -Conversely, [background tasks](/clients/tasks) are **modern-only**: the tasks capability is negotiated over `2026-07-28` connections, so `mode="legacy"` never triggers one and a task-enabled tool just runs synchronously. - -A FastMCP server serves both eras, so a default client negotiates the modern one and the session-dependent calls raise an era-specific error there. Pinning the handshake restores them. +```python +client = Client("https://example.com/mcp", mode="auto") +``` You can also pin a specific modern protocol version to adopt it directly, without a discovery probe: @@ -197,29 +187,23 @@ You can also pin a specific modern protocol version to adopt it directly, withou client = Client("https://example.com/mcp", mode="2026-07-28") ``` -Once connected, the negotiated version, server identity, capabilities, and instructions are available as properties. They are populated from either the legacy `InitializeResult` or modern `DiscoverResult`, and reset to `None` when the client disconnects. `instructions` is also `None` when the server does not provide any. - -When you pin a modern version directly, the client skips discovery and adopts that version with minimal synthesized metadata. In that mode, `server_info` has an empty name and `instructions` is `None`. +Once connected, the negotiated version and the server's advertised capabilities are available as properties. Both are populated regardless of which era was negotiated, and both are `None` while the client is disconnected. ```python async with Client("https://example.com/mcp", mode="auto") as client: print(client.protocol_version) # e.g. "2026-07-28" - print(client.server_info) # Implementation | None print(client.server_capabilities) # ServerCapabilities | None - print(client.instructions) # str | None ``` <Note> -`mode="auto"` is the default as of FastMCP 4.0. Earlier versions defaulted to `"legacy"`. If a server behaves unexpectedly under discovery, or you depend on the legacy `initialize` result, pin the old behavior with `Client(..., mode="legacy")`. - -The SSE transport is legacy-only — it cannot carry the sessionless modern era — so a client connecting over SSE always negotiates the legacy handshake, even under `mode="auto"`. A multi-server config (`MCPConfigTransport` with more than one server) is likewise legacy-only, because it mounts each backend behind a legacy-era proxy; a single-server config mirrors its one backend transport's era. +`mode="auto"` is not the default yet — the conservative `"legacy"` remains the default to preserve byte-identical behavior against pre-2026 servers. Whether `"auto"` becomes the default is a future release decision. </Note> ## Response caching <VersionBadge version="4.0.0" /> -The client can cache the results of `list_tools`, `list_resources`, and `list_prompts` so that repeated calls avoid a network round-trip. Caching is opt-in and honors the server's own cache hints, so it only takes effect against modern-era servers that advertise them — a cache is inert on a legacy connection. +The client can cache the results of `list_tools`, `list_resources`, `list_prompts`, and `read_resource` so that repeated calls avoid a network round-trip. Caching is opt-in and honors the server's own cache hints, so it only takes effect against modern-era servers that advertise them — a cache is inert on a legacy connection. Enable the default in-memory cache by passing `cache=True`. It respects the `ttlMs` and `cacheScope` hints the server attaches to each response. @@ -243,7 +227,7 @@ config = CacheConfig(target_id="weather-api", default_ttl_ms=60_000) client = Client("https://example.com/mcp", mode="auto", cache=config) ``` -The high-level `list_tools`, `list_resources`, and `list_prompts` methods always use the cache when one is configured. To override the behavior for a single call, use the lower-level `list_tools_mcp`, `list_resources_mcp`, `list_resource_templates_mcp`, and `list_prompts_mcp` variants, which accept a `cache_mode` argument: `"use"` (the default) serves and stores, `"refresh"` stores a fresh result without serving a cached one, and `"bypass"` skips the cache entirely. +The high-level `list_tools`, `list_resources`, `list_prompts`, and `read_resource` methods always use the cache when one is configured. To override the behavior for a single call, use the lower-level `*_mcp` variants, which accept a `cache_mode` argument: `"use"` (the default) serves and stores, `"refresh"` stores a fresh result without serving a cached one, and `"bypass"` skips the cache entirely. ```python async with client: @@ -271,31 +255,6 @@ client = Client("https://example.com/mcp", mode="auto", cache=config) The adapter serializes each result through a type-tagged envelope validated against an allowlist of cacheable result models, so a value naming an unknown type is treated as a cache miss rather than deserialized blindly. Each store instance owns its own collection namespace; `clear()` affects only that namespace, never another tenant's entries. -## Client extensions - -<VersionBadge version="4.0.0" /> - -Client extensions (SEP-2133) are the advanced mechanism a client uses to opt into vendor capabilities that live outside the core protocol. An extension is a `ClientExtension` instance that bundles three things: a capability *advertisement* the server can read, one or more *result claims* that let the client parse extra `tools/call` result shapes, and *notification bindings* that observe server notifications the core protocol doesn't define. Pass a sequence of them to `extensions=`. - -```python -from fastmcp import Client -from myproject.extensions import AppsExtension - -client = Client("https://example.com/mcp", extensions=[AppsExtension()]) -``` - -Each extension's contributions are threaded into the underlying session. FastMCP folds in its own internal extension for [background tasks](/clients/tasks) automatically, and your own extensions *compose* with it rather than replacing it — pass your own tasks extension with the same identifier if you need to override it. When a tool returns a shape an extension claims, `client.call_tool()` resolves it transparently through the owning claim's resolver and hands you back an ordinary result. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake. - -For the rare case where you need to register additional result claims against an extension that is already advertised, pass them through `result_claims=`, keyed by the extension's identifier. Prefer declaring claims on the extension itself; this parameter merges extra claims with an extension's own. - -```python -client = Client( - "https://example.com/mcp", - extensions=[AppsExtension()], - result_claims={"example.com/apps": [extra_claim]}, -) -``` - ## Operations FastMCP clients interact with three types of server components. @@ -337,8 +296,6 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications. -Sampling, elicitation, and roots are the requests a server makes of the client. A server reaches your handler by whichever route its [era](#protocol-negotiation) allows — pushed down the open session on the handshake, returned as an input-required result on the modern protocol — and both routes dispatch to the same handler, so one registration covers both. Logging and progress arrive as notifications on the response stream and work in either era. - ```python from fastmcp import Client from fastmcp.client.logging import LogMessage diff --git a/docs/clients/elicitation.mdx b/docs/clients/elicitation.mdx index 73cc6fdb7..840e426d2 100644 --- a/docs/clients/elicitation.mdx +++ b/docs/clients/elicitation.mdx @@ -13,10 +13,6 @@ Use this when you need to respond to server requests for user input during tool Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context. -<Note> -**These sections show the server-initiated flow, which the handshake-era protocol uses.** On `2026-07-28` the server asks by returning a request instead — see [input-required rounds](#input-required-rounds). One `elicitation_handler` serves both, so the examples below pin `mode="legacy"` only to exercise the pushed form. -</Note> - ## Handler Template ```python @@ -34,8 +30,8 @@ async def elicitation_handler( Args: message: The prompt to display to the user - response_type: Python dataclass type for form responses (None for URL requests or empty schemas) - params: Original MCP elicitation parameters + response_type: Python dataclass type for the response (None if no data expected) + params: Original MCP elicitation parameters including raw JSON schema context: Request context with metadata Returns: @@ -48,24 +44,18 @@ async def elicitation_handler( if not user_input: return ElicitResult(action="decline") - # URL requests and empty-object schemas have no response type to construct, - # so accepting is the whole response. - if response_type is None: - return ElicitResult(action="accept") - # Create response using the provided dataclass type return response_type(value=user_input) client = Client( "my_mcp_server.py", - mode="legacy", elicitation_handler=elicitation_handler, ) ``` ## How It Works -When a server needs user input, it sends an elicitation request with a message prompt. Form elicitation requests include a JSON schema describing the expected response structure, and FastMCP automatically converts that schema into a Python dataclass type. URL elicitation requests and empty-object schemas use `response_type=None`. +When a server needs user input, it sends an elicitation request with a message prompt and a JSON schema describing the expected response structure. FastMCP automatically converts this schema into a Python dataclass type, making it easy to construct properly typed responses without manually parsing JSON schemas. The handler receives four parameters: @@ -75,11 +65,11 @@ The handler receives four parameters: </ResponseField> <ResponseField name="response_type" type="type | None"> - A Python dataclass type that FastMCP created from a form request's JSON schema. Use this to construct your response with proper typing. For URL requests or empty-object schemas, this will be `None`. + A Python dataclass type that FastMCP created from the server's JSON schema. Use this to construct your response with proper typing. If the server requests an empty object, this will be `None`. </ResponseField> <ResponseField name="params" type="ElicitRequestParams"> - The original MCP elicitation parameters. Form requests carry the raw JSON schema on `params.requested_schema`; URL requests carry `params.url` instead and have no schema. + The original MCP elicitation parameters, including the raw JSON schema in `params.requested_schema` </ResponseField> <ResponseField name="context" type="RequestContext"> @@ -143,7 +133,6 @@ async def elicitation_handler(message, response_type, params, context): client = Client( "my_mcp_server.py", - mode="legacy", elicitation_handler=elicitation_handler ) ``` @@ -152,7 +141,7 @@ client = Client( <VersionBadge version="4.0.0" /> -On protocol version `2026-07-28` and later, a server can ask for input before it returns a final result. Nothing is held open: the tool *returns* a description of what it needs, which completes that round as an ordinary response, and the client answers by issuing a **new** `call_tool`, `get_prompt`, or `read_resource` request carrying the answer. `fastmcp.Client` drives that loop for you — it fulfils each round's requests using the callbacks you already configured (your `elicitation_handler`, `sampling_handler`, and roots) and repeats until the call reaches a terminal result. No extra wiring is needed beyond the handlers described above. +Modern-era servers (protocol version `2026-07-28` and later) can pause a `call_tool`, `get_prompt`, or `read_resource` call to ask for input before producing a final result. When this happens, the client answers each round automatically using the callbacks you already configured — your `elicitation_handler`, `sampling_handler`, and roots — and retries until the call reaches a terminal result. No extra wiring is needed beyond the handlers described above. The `input_required_max_rounds` parameter caps how many rounds the client will answer before giving up, guarding against a server that never terminates. It defaults to `10`. diff --git a/docs/clients/fastmcp-remote.mdx b/docs/clients/fastmcp-remote.mdx index dfc36a82b..ee218afe0 100644 --- a/docs/clients/fastmcp-remote.mdx +++ b/docs/clients/fastmcp-remote.mdx @@ -56,7 +56,7 @@ Pass the full MCP endpoint URL for the remote server. Many FastMCP HTTP servers `fastmcp-remote` starts a local stdio bridge, then connects to the upstream server when the MCP host initializes that bridge. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or authentication cannot complete, initialization fails and the host should report the remote server as failed. After initialization succeeds, later tool, resource, prompt, and ping requests continue to proxy through the same remote server configuration. -OAuth is enabled automatically unless you provide an `Authorization` header or pass `--auth none`. The first connection opens the browser-based OAuth flow when the server requires authentication, then stores tokens locally for future runs. +OAuth is enabled automatically for HTTPS servers. The first connection opens the browser-based OAuth flow when the server requires authentication, then stores tokens locally for future runs. To pass a bearer token or another custom header directly, provide `--header` in `Name: Value` form. The header name ends at the first colon, so values can contain additional colons. Quote the header when the value contains spaces, just like any other shell argument. An `Authorization` header disables OAuth by default: diff --git a/docs/v3/clients/generate-cli.mdx b/docs/clients/generate-cli.mdx similarity index 100% rename from docs/v3/clients/generate-cli.mdx rename to docs/clients/generate-cli.mdx diff --git a/docs/clients/logging.mdx b/docs/clients/logging.mdx index 407b1ebd5..eea9ff322 100644 --- a/docs/clients/logging.mdx +++ b/docs/clients/logging.mdx @@ -28,32 +28,12 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -LOGGING_LEVEL_MAP = { - "DEBUG": logging.DEBUG, - "INFO": logging.INFO, - "NOTICE": logging.INFO, - "WARNING": logging.WARNING, - "ERROR": logging.ERROR, - "CRITICAL": logging.CRITICAL, - "ALERT": logging.CRITICAL, - "EMERGENCY": logging.CRITICAL, -} +LOGGING_LEVEL_MAP = logging.getLevelNamesMapping() async def log_handler(message: LogMessage): """Forward MCP server logs to Python's logging system.""" - data = message.data - if isinstance(data, dict): - msg = data.get('msg', data) - extra = data.get('extra') - else: - msg = data - extra = None - - # Python's logging requires `extra` to be a mapping, but a server can send - # any JSON value, so fold anything else into the message instead. - if extra is not None and not isinstance(extra, dict): - msg = f"{msg} ({extra})" - extra = None + msg = message.data.get('msg') + extra = message.data.get('extra') level = LOGGING_LEVEL_MAP.get(message.level.upper(), logging.INFO) logger.log(level, msg, extra=extra) @@ -75,20 +55,19 @@ The handler receives a `LogMessage` object: The logger name (may be None) </ResponseField> -<ResponseField name="data" type="Any"> - The JSON-serializable log payload sent by the server. FastMCP's structured logger uses a dictionary with `msg` and `extra` keys, but other MCP servers may send any JSON value. +<ResponseField name="data" type="dict"> + The log payload, containing `msg` and `extra` keys </ResponseField> </Card> ## Structured Logs -The `message.data` attribute contains the server's JSON-serializable log payload. FastMCP servers commonly send a dictionary with `msg` and `extra` keys, which enables structured logging with rich contextual information. +The `message.data` attribute is a dictionary containing the log payload. This enables structured logging with rich contextual information. ```python async def detailed_log_handler(message: LogMessage): - data = message.data - msg = data.get('msg', data) if isinstance(data, dict) else data - extra = data.get('extra') if isinstance(data, dict) else None + msg = message.data.get('msg') + extra = message.data.get('extra') if message.level == "error": print(f"ERROR: {msg} | Details: {extra}") diff --git a/docs/clients/notifications.mdx b/docs/clients/notifications.mdx index b771c903e..aa7d47058 100644 --- a/docs/clients/notifications.mdx +++ b/docs/clients/notifications.mdx @@ -31,8 +31,6 @@ async def message_handler(message): print("Resources have changed") elif method == "notifications/prompts/list_changed": print("Prompts have changed") - elif method == "notifications/resources/updated": - print("A resource was updated") client = Client( "my_mcp_server.py", @@ -47,23 +45,23 @@ For fine-grained targeting, subclass `MessageHandler` to use specific hooks: ```python from fastmcp import Client from fastmcp.client.messages import MessageHandler -import mcp.types +import mcp_types class MyMessageHandler(MessageHandler): async def on_tool_list_changed( - self, notification: mcp.types.ToolListChangedNotification + self, notification: mcp_types.ToolListChangedNotification ) -> None: """Handle tool list changes.""" print("Tool list changed - refreshing available tools") async def on_resource_list_changed( - self, notification: mcp.types.ResourceListChangedNotification + self, notification: mcp_types.ResourceListChangedNotification ) -> None: """Handle resource list changes.""" print("Resource list changed") async def on_prompt_list_changed( - self, notification: mcp.types.PromptListChangedNotification + self, notification: mcp_types.PromptListChangedNotification ) -> None: """Handle prompt list changes.""" print("Prompt list changed") @@ -78,7 +76,7 @@ client = Client( ```python from fastmcp.client.messages import MessageHandler -import mcp.types +import mcp_types class MyMessageHandler(MessageHandler): async def on_message(self, message) -> None: @@ -86,49 +84,37 @@ class MyMessageHandler(MessageHandler): pass async def on_notification( - self, notification: mcp.types.ServerNotification + self, notification: mcp_types.ServerNotification ) -> None: """Called for notifications (fire-and-forget).""" pass async def on_tool_list_changed( - self, notification: mcp.types.ToolListChangedNotification + self, notification: mcp_types.ToolListChangedNotification ) -> None: """Called when the server's tool list changes.""" pass async def on_resource_list_changed( - self, notification: mcp.types.ResourceListChangedNotification + self, notification: mcp_types.ResourceListChangedNotification ) -> None: """Called when the server's resource list changes.""" pass async def on_prompt_list_changed( - self, notification: mcp.types.PromptListChangedNotification + self, notification: mcp_types.PromptListChangedNotification ) -> None: """Called when the server's prompt list changes.""" pass async def on_progress( - self, notification: mcp.types.ProgressNotification + self, notification: mcp_types.ProgressNotification ) -> None: """Called for progress updates during long-running operations.""" pass - async def on_resource_updated( - self, notification: mcp.types.ResourceUpdatedNotification - ) -> None: - """Called when a specific resource changes.""" - pass - - async def on_cancelled( - self, notification: mcp.types.CancelledNotification - ) -> None: - """Called when a request is cancelled.""" - pass - async def on_logging_message( - self, notification: mcp.types.LoggingMessageNotification + self, notification: mcp_types.LoggingMessageNotification ) -> None: """Called for log messages from the server.""" pass @@ -141,14 +127,14 @@ A practical example of maintaining a tool cache that refreshes when tools change ```python from fastmcp import Client from fastmcp.client.messages import MessageHandler -import mcp.types +import mcp_types class ToolCacheHandler(MessageHandler): def __init__(self): self.cached_tools = [] async def on_tool_list_changed( - self, notification: mcp.types.ToolListChangedNotification + self, notification: mcp_types.ToolListChangedNotification ) -> None: """Clear tool cache when tools change.""" print("Tools changed - clearing cache") diff --git a/docs/clients/prompts.mdx b/docs/clients/prompts.mdx index fcebfe967..5d1bd7377 100644 --- a/docs/clients/prompts.mdx +++ b/docs/clients/prompts.mdx @@ -21,7 +21,7 @@ Request a rendered prompt with `get_prompt()`: async with client: # Simple prompt without arguments result = await client.get_prompt("welcome_message") - # result -> mcp_types.GetPromptResult + # result -> fastmcp.types.GetPromptResult # Access the generated messages for message in result.messages: @@ -128,12 +128,12 @@ See [Metadata](/servers/versioning#version-discovery) for how to discover availa ## Multi-Server Clients -When using multi-server clients, prompts are mounted with the server name as a prefix, just like tools: +When using multi-server clients, prompts are accessible directly without prefixing: ```python async with client: # Multi-server client - result1 = await client.get_prompt("weather_weather_prompt", {"city": "London"}) - result2 = await client.get_prompt("assistant_assistant_prompt", {"query": "help"}) + result1 = await client.get_prompt("weather_prompt", {"city": "London"}) + result2 = await client.get_prompt("assistant_prompt", {"query": "help"}) ``` ## Raw Protocol Access @@ -143,5 +143,5 @@ For complete control, use `get_prompt_mcp()` which returns the full MCP protocol ```python async with client: result = await client.get_prompt_mcp("example_prompt", {"arg": "value"}) - # result -> mcp_types.GetPromptResult + # result -> fastmcp.types.GetPromptResult ``` diff --git a/docs/clients/resources.mdx b/docs/clients/resources.mdx index 041ad0978..197ec6c3e 100644 --- a/docs/clients/resources.mdx +++ b/docs/clients/resources.mdx @@ -58,25 +58,18 @@ async with client: Binary resources include images, PDFs, and other non-text data: -Binary resources arrive as `BlobResourceContents`, whose `blob` field is a base64 **string**, so decode it before writing bytes to disk: - ```python -import base64 - -from mcp_types import BlobResourceContents - async with client: content = await client.read_resource("resource://images/logo.png") for item in content: - if isinstance(item, BlobResourceContents): - data = base64.b64decode(item.blob) - print(f"Binary content: {len(data)} bytes") + if hasattr(item, 'blob'): + print(f"Binary content: {len(item.blob)} bytes") print(f"MIME type: {item.mime_type}") # Save to file with open("downloaded_logo.png", "wb") as f: - f.write(data) + f.write(item.blob) ``` ## Multi-Server Clients @@ -113,5 +106,5 @@ For complete control, use `read_resource_mcp()` which returns the full MCP proto ```python async with client: result = await client.read_resource_mcp("resource://example") - # result -> mcp_types.ReadResourceResult + # result -> fastmcp.types.ReadResourceResult ``` diff --git a/docs/clients/roots.mdx b/docs/clients/roots.mdx index 08f5c9786..0370c119a 100644 --- a/docs/clients/roots.mdx +++ b/docs/clients/roots.mdx @@ -1,7 +1,7 @@ --- title: Client Roots sidebarTitle: Roots -description: Tell servers which local paths your client can reach. +description: Provide local context and resource boundaries to MCP servers. icon: folder-tree --- @@ -11,26 +11,24 @@ import { VersionBadge } from '/snippets/version-badge.mdx' Use this when you need to tell servers what local resources the client has access to. -A root is a path your client is willing to expose — a project directory, a workspace, a document store. Servers read them to scope their work, so a tool that searches files searches where you pointed it, and a server that gets no roots has to ask the user for paths instead. Roots describe where the client can reach; the server takes them as its working boundary. - -Register them once with `roots=`, and the client answers however the server asks. A handshake-era server pushes a `roots/list` request down the open session and reads the reply mid-call; a modern (`2026-07-28`) server has no such channel, so it returns a roots request and `fastmcp.Client` fulfils it from the same registration and re-issues the call with the answer attached. The default `mode="auto"` negotiates whichever era the server speaks, so the examples below work on either — see [protocol negotiation](/clients/client#protocol-negotiation) for how that choice is made, and [the guard pattern](/servers/elicitation#sampling-and-roots) for how a server issues the modern form. +Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses. ## Static Roots -When the paths are known up front, pass them as a list. The client holds them for the life of the connection and hands back the same set every time a server asks. +Provide a list of roots when creating the client: ```python from fastmcp import Client client = Client( "my_mcp_server.py", - roots=["file:///path/to/root1", "file:///path/to/root2"] + roots=["/path/to/root1", "/path/to/root2"] ) ``` ## Dynamic Roots -Pass a callback instead when the roots depend on something the client learns at runtime, such as the workspace the user has open. It runs at the moment a server asks, on either route, and receives the request context so you can see which request it is answering: +Use a callback to compute roots dynamically when the server requests them: ```python from fastmcp import Client @@ -38,7 +36,7 @@ from fastmcp.client.roots import RequestContext async def roots_callback(context: RequestContext) -> list[str]: print(f"Server requested roots (Request ID: {context.request_id})") - return ["file:///path/to/root1", "file:///path/to/root2"] + return ["/path/to/root1", "/path/to/root2"] client = Client( "my_mcp_server.py", diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx index f2dfe82e3..b0655989b 100644 --- a/docs/clients/sampling.mdx +++ b/docs/clients/sampling.mdx @@ -1,7 +1,7 @@ --- title: LLM Sampling sidebarTitle: Sampling -description: Answer a server's request for an LLM completion. +description: Handle server-initiated LLM completion requests. icon: robot --- @@ -9,46 +9,52 @@ import { VersionBadge } from "/snippets/version-badge.mdx"; <VersionBadge version="2.0.0" /> -Use this when a server asks your client to run an LLM completion on its behalf. +Use this when you need to respond to server requests for LLM completions. -Sampling is how a server borrows your model. Rather than hold an API key of its own, the server describes the messages it wants completed and asks you to run them — you pick the model, and you pay for the tokens. Your side of that arrangement is one function, a **sampling handler**, registered when you create the client. - -The handler receives the conversation the server wants completed, the parameters it asked for, and a request context carrying metadata about the call. Return the generated text as a string and FastMCP wraps it in the protocol's result for you; return a `CreateMessageResult` yourself when you want to report the real model name or hand back content that isn't text. If the handler raises, the client sends the error back in place of a completion and the server's tool decides what to do about it. +MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made. ## Handler Template ```python from fastmcp import Client from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext -from mcp.types import TextContent - async def sampling_handler( messages: list[SamplingMessage], params: SamplingParams, - context: RequestContext, + context: RequestContext ) -> str: - """Run the server's messages against your LLM and return the completion.""" - conversation = [ - f"{message.role}: {message.content.text}" - for message in messages - if isinstance(message.content, TextContent) - ] + """ + Handle server requests for LLM completions. + + Args: + messages: Conversation messages to send to the LLM + params: Sampling parameters (temperature, max_tokens, etc.) + context: Request context with metadata + + Returns: + Generated text response from your LLM + """ + # Extract message content + conversation = [] + for message in messages: + content = message.content.text if hasattr(message.content, 'text') else str(message.content) + conversation.append(f"{message.role}: {content}") + + # Use the system prompt if provided system_prompt = params.system_prompt or "You are a helpful assistant." - # Call your LLM here with `conversation` and `system_prompt`. + # Integrate with your LLM service here return "Generated response based on the messages" - -client = Client("my_mcp_server.py", sampling_handler=sampling_handler) +client = Client( + "my_mcp_server.py", + sampling_handler=sampling_handler, +) ``` -The client answers with this handler however the server asks for a completion. The default `mode="auto"` negotiates whichever protocol era the server speaks, and one handler covers both of the routes an era can use — see [Request Routes](#request-routes). - ## Handler Parameters -Everything the server sends arrives in the first two arguments. The messages are the conversation to complete; the parameters are how the server would like it completed. You decide how much of that to honor, since the client owns the model — a preference your provider cannot express is yours to ignore. - <Card icon="code" title="SamplingMessage"> <ResponseField name="role" type='Literal["user", "assistant"]'> The role of the message @@ -60,11 +66,11 @@ Everything the server sends arrives in the first two arguments. The messages are </Card> <Card icon="code" title="SamplingParams"> -<ResponseField name="system_prompt" type="str | None"> +<ResponseField name="systemPrompt" type="str | None"> Optional system prompt the server wants to use </ResponseField> -<ResponseField name="model_preferences" type="ModelPreferences | None"> +<ResponseField name="modelPreferences" type="ModelPreferences | None"> Server preferences for model selection (hints, cost/speed/intelligence priorities) </ResponseField> @@ -72,11 +78,11 @@ Everything the server sends arrives in the first two arguments. The messages are Sampling temperature </ResponseField> -<ResponseField name="max_tokens" type="int"> +<ResponseField name="maxTokens" type="int"> Maximum tokens to generate </ResponseField> -<ResponseField name="stop_sequences" type="list[str] | None"> +<ResponseField name="stopSequences" type="list[str] | None"> Stop sequences for sampling </ResponseField> @@ -84,14 +90,14 @@ Everything the server sends arrives in the first two arguments. The messages are Tools the LLM can use during sampling </ResponseField> -<ResponseField name="tool_choice" type="ToolChoice | None"> +<ResponseField name="toolChoice" type="ToolChoice | None"> Tool usage behavior (`auto`, `required`, or `none`) </ResponseField> </Card> ## Built-in Handlers -Writing the provider call yourself is rarely worth it. FastMCP ships handlers for OpenAI, Anthropic, and Google Gemini that implement the full sampling API, tool use included, and translate the protocol's parameters into each provider's own. Give one a default model and pass it where your own handler would go. Write a custom handler when you need routing across providers, caching, or a provider FastMCP does not cover. +FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use. ### OpenAI Handler @@ -107,11 +113,9 @@ client = Client( ) ``` -Point the handler at any OpenAI-compatible API, including a local model server, by passing your own provider client: +For OpenAI-compatible APIs (like local models): ```python -from fastmcp import Client -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler from openai import AsyncOpenAI client = Client( @@ -124,7 +128,7 @@ client = Client( ``` <Note> -Install the OpenAI handler with `pip install 'fastmcp[openai]'`. +Install the OpenAI handler with `pip install fastmcp[openai]`. </Note> ### Anthropic Handler @@ -142,7 +146,7 @@ client = Client( ``` <Note> -Install the Anthropic handler with `pip install 'fastmcp[anthropic]'`. +Install the Anthropic handler with `pip install fastmcp[anthropic]`. </Note> ### Google Gemini Handler @@ -160,35 +164,27 @@ client = Client( ``` <Note> -Install the Google Gemini handler with `pip install 'fastmcp[gemini]'`. +Install the Google Gemini handler with `pip install fastmcp[gemini]`. </Note> -The [source of these handlers](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) is the best reference for writing your own. +## Sampling Capabilities -## Tool Use - -A sampling request can carry tools. When it does, your handler passes them to the model and returns whatever comes back, tool calls included — the server executes the tools itself and sends a follow-up sampling request with the results if it needs another turn. Your handler never runs a tool. - -Registering any `sampling_handler` advertises full sampling support, tools included. A handler that only generates text should say so, so servers know not to send tools it will drop: +When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers: ```python -from fastmcp import Client -from mcp.types import SamplingCapability - - -async def text_only_handler(messages, params, context) -> str: - return "Generated response based on the messages" - +from fastmcp.types import SamplingCapability client = Client( "my_mcp_server.py", - sampling_handler=text_only_handler, - sampling_capabilities=SamplingCapability(), + sampling_handler=basic_handler, + sampling_capabilities=SamplingCapability(), # No tool support ) ``` -## Request Routes +## Tool Execution -Servers reach your handler by two routes, and which one applies depends on the protocol era the connection negotiated. A handshake-era server pushes a `sampling/createMessage` request down the open session while a tool is running and waits for the reply. A modern (`2026-07-28`) connection has no such channel, so the tool ends its round by returning a request for a completion instead; the client answers from your handler and calls the tool again with the result attached. +Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results. -One registration covers both, so this is rarely something you configure — it matters only when you pin an era, since `mode="legacy"` is the sole route that carries a pushed request. See [protocol negotiation](/clients/client#protocol-negotiation) for how the era is chosen, and [Sampling](/servers/sampling) under Servers for how a server issues these requests. +<Tip> +To implement a custom sampling handler, see the [handler source code](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) as a reference. +</Tip> diff --git a/docs/clients/tasks.mdx b/docs/clients/tasks.mdx index 71a1384c9..ce27520e4 100644 --- a/docs/clients/tasks.mdx +++ b/docs/clients/tasks.mdx @@ -1,138 +1,180 @@ --- title: Background Tasks sidebarTitle: Tasks -description: Call long-running tools without blocking, and answer questions they ask mid-run. +description: Execute operations asynchronously and track their progress. icon: clock tag: "NEW" --- import { VersionBadge } from "/snippets/version-badge.mdx" -<VersionBadge version="4.0.0" /> +<VersionBadge version="2.14.0" /> -Some tool calls take a while. The MCP background tasks extension lets a server run one in the background instead of holding the request open, and FastMCP's client drives the whole thing for you — most of the time you don't need to know a call was tasked at all. +Use this when you need to run long operations asynchronously while doing other work. -<Note> -**Client task support is opt-in.** Install the `fastmcp-tasks` package (`pip install "fastmcp[tasks]"`) and import it — importing `fastmcp_tasks` anywhere (which you do to use `call_tool_task`) enables task support for every `Client` in the process. Without it, a `Client` never advertises the tasks capability, so the server runs its calls synchronously and background tasks simply don't happen. +The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results. -**Tasks also require the modern protocol.** The capability is negotiated over `2026-07-28` connections. `mode="auto"` (the client default) negotiates it automatically; `mode="legacy"` never does. See [protocol negotiation](/clients/client#protocol-negotiation). -</Note> +## Requesting Background Execution -## Transparent Calls - -With task support enabled, just call the tool. If the server runs it as a background task, `call_tool` polls it to completion under the hood and returns the same result you'd get from a synchronous call — the task is invisible. - -```python -import fastmcp_tasks # enables client task support -from fastmcp import Client - -async with Client(server, mode="auto") as client: - result = await client.call_tool("slow_computation", {"duration": 10}) - print(result.data) -``` - -This is the right default for most code: it works whether or not the server actually tasks the call, so you can write ordinary tool-calling code without checking server capabilities. - -## Driving a Task Explicitly - -When you want to do other work while a task runs — or check on it, or cancel it — use `call_tool_task` instead. It returns a `ToolTask` handle immediately rather than waiting for completion. +Pass `task=True` to run an operation as a background task: ```python from fastmcp import Client -from fastmcp_tasks import call_tool_task -async with Client(server, mode="auto") as client: - task = await call_tool_task(client, "slow_computation", {"duration": 10}) +async with Client(server) as client: + # Start a background task + task = await client.call_tool("slow_computation", {"duration": 10}, task=True) + print(f"Task started: {task.task_id}") # Do other work while it runs... + # Get the result when ready result = await task.result() ``` -`call_tool_task` requires the server to actually run the call as a task — if the tool isn't `task=True`, or the server doesn't have the tasks extension registered, it raises `ToolError`. Use it when you specifically need the handle; use `call_tool` when you just want the result. +This works with tools, resources, and prompts: + +```python +tool_task = await client.call_tool("my_tool", args, task=True) +resource_task = await client.read_resource("file://large.txt", task=True) +prompt_task = await client.get_prompt("my_prompt", args, task=True) +``` + +## Task API + +All task types share a common interface. + +### Getting Results + +Call `await task.result()` or simply `await task` to block until the task completes: + +```python +task = await client.call_tool("analyze", {"text": "hello"}, task=True) + +# Wait for result (blocking) +result = await task.result() +# or: result = await task +``` ### Checking Status +Check the current status without blocking: + ```python status = await task.status() -print(f"{status.status}: {status.status_message}") -# status.status is "working", "input_required", "completed", "failed", or "cancelled" +print(f"{status.status}: {status.statusMessage}") +# status.status is "working", "completed", "failed", or "cancelled" ``` ### Waiting with Control -`task.wait()` polls until a terminal state (or a specific one you name), without answering any input the task asks for — use it when you want to observe an `input_required` pause yourself rather than have it answered automatically. +Use `task.wait()` for more control over waiting: ```python # Wait up to 30 seconds for completion status = await task.wait(timeout=30.0) # Wait for a specific state -status = await task.wait(state="input_required", timeout=30.0) +status = await task.wait(state="completed", timeout=30.0) ``` -### Getting the Result - -`task.result()` drives the task the rest of the way — including answering any input it asks for — and returns the finished result, same as `client.call_tool` would. Awaiting the task directly is shorthand for this. - -```python -result = await task.result() -# or: result = await task -``` - -By default a failed or cancelled task raises `ToolError`. Pass `raise_on_error=False` to `call_tool_task` to get an error result back instead. - ### Cancellation +Cancel a running task: + ```python await task.cancel() ``` -Cancellation is cooperative — the task may still finish before the server notices the request. +## Status Updates -## Answering Questions Mid-Task +Register callbacks to receive real-time status updates as the server reports progress: -A task can pause partway through to ask a question, the same way a foreground [multi-round-trip](/clients/elicitation#input-required-rounds) tool does. Pass an `elicitation_handler` and both `call_tool` and `task.result()` answer it automatically as part of driving the task to completion: +```python +def on_status_change(status): + print(f"Task {status.taskId}: {status.status} - {status.statusMessage}") + +task.on_status_change(on_status_change) + +# Async callbacks work too +async def on_status_async(status): + await log_status(status) + +task.on_status_change(on_status_async) +``` + +### Handler Template ```python from fastmcp import Client -async def handle_elicitation(message, response_type, params, context): - return {"cuisine": "Thai", "vegetarian": True} +def status_handler(status): + """ + Handle task status updates. -async with Client(server, mode="auto", elicitation_handler=handle_elicitation) as client: - result = await client.call_tool("plan_dinner", {}) - print(result.data) + Args: + status: Task status object with: + - taskId: Unique task identifier + - status: "working", "completed", "failed", or "cancelled" + - statusMessage: Optional progress message from server + """ + if status.status == "working": + print(f"Progress: {status.statusMessage}") + elif status.status == "completed": + print("Task completed") + elif status.status == "failed": + print(f"Task failed: {status.statusMessage}") + +task.on_status_change(status_handler) ``` -Without an `elicitation_handler`, a task that asks for input raises `ToolError` rather than hanging. See [server-side background tasks](/servers/tasks#gathering-input-mid-task) for how a tool asks a question in the first place. +## Graceful Degradation + +You can always pass `task=True` regardless of whether the server supports background tasks. Per the MCP specification, servers without task support execute the operation immediately and return the result inline. + +```python +task = await client.call_tool("my_tool", args, task=True) + +if task.returned_immediately: + print("Server executed immediately (no background support)") +else: + print("Running in background") + +# Either way, this works +result = await task.result() +``` + +This lets you write task-aware client code without worrying about server capabilities. ## Example -Putting it together, here is a client that submits a background task with `call_tool_task` and awaits its result: - ```python import asyncio from fastmcp import Client -from fastmcp_tasks import call_tool_task async def main(): - async with Client(server, mode="auto") as client: - # Return immediately and drive the task yourself - task = await call_tool_task(client, "slow_computation", {"duration": 10}) - print(f"Task started: {task.task_id}") + async with Client(server) as client: + # Start background task + task = await client.call_tool( + "slow_computation", + {"duration": 10}, + task=True, + ) - # Do other work while the task runs - while True: - status = await task.status() - if status.status in ("completed", "failed", "cancelled"): - break - print(f"Still working... ({status.status})") - await asyncio.sleep(1) + # Subscribe to updates + def on_update(status): + print(f"Progress: {status.statusMessage}") + task.on_status_change(on_update) + + # Do other work while task runs + print("Doing other work...") + await asyncio.sleep(2) + + # Wait for completion and get result result = await task.result() - print(f"Result: {result.data}") + print(f"Result: {result.content}") asyncio.run(main()) ``` diff --git a/docs/clients/tools.mdx b/docs/clients/tools.mdx index 77389296e..9422c30a8 100644 --- a/docs/clients/tools.mdx +++ b/docs/clients/tools.mdx @@ -80,7 +80,7 @@ async with client: Fully hydrated Python objects with complex type support (datetimes, UUIDs, custom classes). FastMCP exclusive. </ResponseField> -<ResponseField name=".content" type="list[mcp_types.ContentBlock]"> +<ResponseField name=".content" type="list[fastmcp.types.ContentBlock]"> Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.). </ResponseField> @@ -173,7 +173,7 @@ For complete control, use `call_tool_mcp()` which returns the raw MCP protocol o ```python async with client: result = await client.call_tool_mcp("my_tool", {"param": "value"}) - # result -> mcp_types.CallToolResult + # result -> fastmcp.types.CallToolResult if result.is_error: print(f"Tool failed: {result.content}") diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index 23acab535..44c70c217 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -16,9 +16,7 @@ Transports handle the underlying connection between your client and MCP servers. STDIO transport communicates with MCP servers through subprocess pipes. When using STDIO, your client launches and manages the server process, controlling its lifecycle and environment. <Warning> -STDIO servers inherit only a small allowlist of environment variables — just enough to locate an interpreter and a home directory. Anything else in your shell, including API keys and other credentials, does not reach the server unless you pass it through `env` explicitly. - -The allowlist is platform-specific. On POSIX systems it is `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, and `USER`; on Windows it is `APPDATA`, `HOMEDRIVE`, `HOMEPATH`, `LOCALAPPDATA`, `PATH`, `PATHEXT`, `PROCESSOR_ARCHITECTURE`, `SYSTEMDRIVE`, `SYSTEMROOT`, `TEMP`, `USERNAME`, and `USERPROFILE`. +STDIO servers run in isolated environments by default. They do not inherit your shell's environment variables. You must explicitly pass any configuration the server needs. </Warning> ```python @@ -44,7 +42,7 @@ client = Client("my_server.py") # Limited - no configuration options ### Environment Variables -Values you pass through `env` are merged on top of the inherited allowlist, so you add configuration rather than replacing the base environment. Anything your server needs beyond those six variables has to be listed explicitly. +Since STDIO servers do not inherit your environment, you need strategies for passing configuration. **Selective forwarding** passes only the variables your server needs: @@ -65,11 +63,7 @@ client = Client(transport) from dotenv import dotenv_values from fastmcp.client.transports import StdioTransport -env = { - key: value - for key, value in dotenv_values(".env").items() - if value is not None -} +env = dotenv_values(".env") transport = StdioTransport(command="python", args=["server.py"], env=env) client = Client(transport) ``` @@ -86,7 +80,7 @@ client = Client(transport) async def efficient_multiple_operations(): async with client: - await client.list_tools() + await client.ping() async with client: # Reuses the same subprocess await client.call_tool("process_data", {"file": "data.csv"}) diff --git a/docs/css/banner.css b/docs/css/banner.css index 036437304..51c191a90 100644 --- a/docs/css/banner.css +++ b/docs/css/banner.css @@ -9,6 +9,7 @@ padding-top: 12px !important; padding-bottom: 12px !important; overflow: hidden !important; + position: relative; } #banner::before { diff --git a/docs/css/language-dropdown.css b/docs/css/language-dropdown.css deleted file mode 100644 index 0eb810545..000000000 --- a/docs/css/language-dropdown.css +++ /dev/null @@ -1,57 +0,0 @@ -/* Language dropdown: injected by language-dropdown.js into the sidebar - footer, to the right of Mintlify's theme selector. Mirrors the almond - theme pill's exact metrics (lg:h-7 desktop / 2.375rem mobile, rounded-full, - border-gray-200/70, dark:border-white/[0.07]) so the two controls read as - one family. */ -#language-switch { - margin-left: auto; - display: inline-flex; - align-items: center; -} - -#language-switch select { - appearance: none; - -webkit-appearance: none; - background-color: transparent; - border: 1px solid rgb(229 231 235 / 0.7); - border-radius: 9999px; - color: rgb(107 114 128); - cursor: pointer; - font-size: 0.75rem; - line-height: 1rem; - height: 2.375rem; - padding: 0 1.375rem 0 0.75rem; - /* Chevron, drawn in the same gray as the label text. */ - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.5rem center; - background-size: 0.7rem; - transition: border-color 0.2s; -} - -@media (min-width: 1024px) { - #language-switch select { - height: 1.75rem; - } -} - -#language-switch select:hover { - color: rgb(75 85 99); - border-color: rgb(229 231 235); -} - -#language-switch select:focus-visible { - outline: 2px solid rgb(45 0 247 / 0.4); - outline-offset: 1px; -} - -.dark #language-switch select { - border-color: rgb(255 255 255 / 0.07); - color: rgb(156 163 175); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); -} - -.dark #language-switch select:hover { - color: rgb(209 213 219); - border-color: rgb(255 255 255 / 0.1); -} diff --git a/docs/css/style.css b/docs/css/style.css index 94d58b4bf..99844f692 100644 --- a/docs/css/style.css +++ b/docs/css/style.css @@ -57,42 +57,6 @@ h6 code:not(pre code) { background: linear-gradient(135deg, #2d00f7 0%, #4cc9f0 100%); } -/* V3 banner - inside content-container, breaks out of padding with negative margins */ -#v3-banner { - display: block; - background: linear-gradient(135deg, #4cc9f0 0%, #2d00f7 100%); - color: white; - text-align: center; - padding: 10px 16px; - font-size: 0.875rem; - font-weight: 600; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - margin: -2rem -2rem 1.5rem -2rem; - width: calc(100% + 4rem); - border-radius: 8px 8px 0 0; -} - -#v3-banner a { - color: white; - text-decoration: underline; - font-weight: 700; -} - -#v3-banner a:hover { - opacity: 0.9; -} - -@media (min-width: 1024px) { - #v3-banner { - margin: -3rem -4rem 1.5rem -4rem; - width: calc(100% + 8rem); - } -} - -.dark #v3-banner { - background: linear-gradient(135deg, #2d00f7 0%, #4cc9f0 100%); -} - diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index f057efe4c..16c9fadfa 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -79,17 +79,17 @@ The ASGI approach shines in production environments where you need reliability a ### Custom Path -By default, your MCP server is accessible at `/mcp` on your domain. You can customize this path to fit your URL structure or avoid conflicts with existing endpoints. This is particularly useful when integrating MCP into an existing application or following specific API conventions. +By default, your MCP server is accessible at `/mcp/` on your domain. You can customize this path to fit your URL structure or avoid conflicts with existing endpoints. This is particularly useful when integrating MCP into an existing application or following specific API conventions. ```python # Option 1: With mcp.run() -mcp.run(transport="http", host="0.0.0.0", port=8000, path="/api/mcp") +mcp.run(transport="http", host="0.0.0.0", port=8000, path="/api/mcp/") # Option 2: With ASGI app -app = mcp.http_app(path="/api/mcp") +app = mcp.http_app(path="/api/mcp/") ``` -Now your server is accessible at `http://localhost:8000/api/mcp`. +Now your server is accessible at `http://localhost:8000/api/mcp/`. ### Authentication @@ -103,7 +103,7 @@ If you're mounting an authenticated server under a path prefix, see [Mounting Au ### Host and Origin Protection -FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it stays opt-in to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments. +FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it remains opt-in in FastMCP 3.x to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments. Think of this as a request guard rather than CORS middleware. It decides whether a request can reach MCP session handling. CORS remains a separate browser response-header policy; configure CORS middleware separately when browser JavaScript must read cross-origin responses. @@ -149,48 +149,6 @@ export FASTMCP_HTTP_ALLOWED_ORIGINS='["https://app.example.com"]' Use `host_origin_protection="auto"` to protect localhost-bound direct servers while allowing ASGI, serverless, and reverse-proxy deployments to keep their existing Host handling unless they configure explicit trust rules. Use `host_origin_protection=False` to keep the request guard disabled. -### Gateway Routing Headers - -<VersionBadge version="4.0.0" /> - -A gateway, load balancer, or reverse proxy in front of your MCP server often needs to route a request before it reads the JSON-RPC body — the body may be an SSE stream, or the gateway may simply want to avoid parsing it. On a connection that negotiates the modern `2026-07-28` protocol, Streamable HTTP clients built on the MCP Python SDK (including FastMCP's own client) attach routing information to each request as HTTP headers so an intermediary can dispatch on headers alone: - -- `Mcp-Method` carries the JSON-RPC method (for example `tools/call`) on every request. -- `Mcp-Name` carries the target's name on named operations — the tool name for `tools/call`, the prompt name for `prompts/get`, the resource URI for `resources/read`. -- `Mcp-Param-*` carries selected argument values for a `tools/call`, one header per opted-in parameter. - -FastMCP's HTTP transport neither strips nor rewrites these headers, so a gateway sees them exactly as the client sent them. The `Host`/`Origin` request guard inspects only `Host` and `Origin` and leaves the routing headers untouched. - -<Warning> -These headers are a feature of the modern `2026-07-28` protocol. A client connected over an earlier protocol revision — including one running in legacy mode or one that has fallen back to a legacy server — sends no routing headers at all. Design gateway routing to require the headers rather than assume their presence: if a request arrives without them, fall back to inspecting the body or route it to a default backend, rather than dropping it. -</Warning> - -To expose an argument as an `Mcp-Param-*` header, annotate the parameter with the `x-mcp-header` JSON Schema extension. FastMCP carries the annotation into the tool's advertised input schema, and a conforming client mirrors the argument into a header named `Mcp-Param-<token>`: - -```python -from typing import Annotated - -from pydantic import Field - -from fastmcp import FastMCP - -mcp = FastMCP("My Server") - -@mcp.tool -def query_tenant( - tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})], - sql: str, -) -> str: - """A call to this tool sends the tenant value as an `Mcp-Param-Tenant` header.""" - ... -``` - -A gateway can now route on `Mcp-Param-Tenant` — for example, pinning each tenant to a dedicated backend — without inspecting the request body. The annotation is only permitted on `string`, `integer`, and `boolean` parameters. These headers advertise routing intent; treat them as untrusted hints, since the server still validates the request body as the source of truth. - -<Tip> -When you put a FastMCP [proxy](/servers/providers/proxy) in front of another server, the proxy re-advertises each backend tool's `x-mcp-header` annotation, so routing headers work across the proxy hop as well. The headers themselves are regenerated per hop rather than forwarded verbatim, since each describes a single HTTP request. -</Tip> - ### Health Checks Health check endpoints are essential for monitoring your deployed server and ensuring it's responding correctly. FastMCP allows you to add custom routes alongside your MCP endpoints, making it easy to implement health checks that work with both deployment approaches. @@ -387,7 +345,7 @@ def analyze(data: str) -> dict: return {"result": f"Analyzed: {data}"} # Create the ASGI app -mcp_app = mcp.http_app(path="/mcp") +mcp_app = mcp.http_app(path='/mcp') # Create a Starlette app and mount the MCP server app = Starlette( @@ -399,7 +357,7 @@ app = Starlette( ) ``` -The MCP endpoint will be available at `/mcp-server/mcp` of the resulting Starlette app. +The MCP endpoint will be available at `/mcp-server/mcp/` of the resulting Starlette app. <Warning> For Streamable HTTP transport, you **must** pass the lifespan context from the FastMCP app to the resulting Starlette app, as nested lifespans are not recognized. Otherwise, the FastMCP server's session manager will not be properly initialized. @@ -418,7 +376,7 @@ from starlette.routing import Mount mcp = FastMCP("MyServer") # Create the ASGI app -mcp_app = mcp.http_app(path="/mcp") +mcp_app = mcp.http_app(path='/mcp') # Create nested application structure inner_app = Starlette(routes=[Mount("/inner", app=mcp_app)]) @@ -428,7 +386,7 @@ app = Starlette( ) ``` -In this setup, the MCP server is accessible at the `/outer/inner/mcp` path. +In this setup, the MCP server is accessible at the `/outer/inner/mcp/` path. ### FastAPI Integration @@ -546,7 +504,7 @@ base_url="http://localhost:8000/api" # Includes mount prefix mcp_path="/mcp" # Internal MCP path, NOT the mount prefix ``` -**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. It sets the `issuer` advertised in the authorization server metadata and the `iss` on issued tokens, while the endpoints in that metadata continue to point at `base_url`. +**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. ```python # Usually not needed - just set base_url and it works @@ -700,7 +658,7 @@ When deploying FastMCP behind a load balancer or running multiple server instanc #### Understanding Sessions -By default, FastMCP's Streamable HTTP transport maintains server-side sessions. A session holds the context a server keeps across multiple requests from the same client, and it carries the handshake-era back-channel that server-initiated requests like [elicitation](/servers/elicitation) push down. +By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions enable stateful MCP features like [elicitation](/servers/elicitation) and [sampling](/servers/sampling), where the server needs to maintain context across multiple requests from the same client. This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally. @@ -783,7 +741,9 @@ If you're using the [OAuth Proxy](/servers/auth/oauth-proxy), FastMCP issues its **Default Behavior (Development Only):** -By default, FastMCP automatically manages cryptographic keys the same way on every platform: the signing key is deterministically derived from your OAuth client secret, so it survives server restarts as long as the secret doesn't change. Suitable **only** for development and local testing. +By default, FastMCP automatically manages cryptographic keys: +- **Mac/Windows**: Keys are generated and stored in your system keyring, surviving server restarts. Suitable **only** for development and local testing. +- **Linux**: Keys are ephemeral (random salt at startup), so tokens are invalidated on restart. This automatic approach is convenient for development but not suitable for production deployments. diff --git a/docs/deployment/prefect-horizon.mdx b/docs/deployment/prefect-horizon.mdx index 68f157c52..b22644181 100644 --- a/docs/deployment/prefect-horizon.mdx +++ b/docs/deployment/prefect-horizon.mdx @@ -5,7 +5,7 @@ description: The MCP platform from the FastMCP team icon: cloud --- -[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=guide_intro) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities. +[Prefect Horizon](https://www.prefect.io/horizon) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities. Horizon includes a **free personal tier for FastMCP users**, making it the fastest way to get a secure, production-ready server URL with built-in OAuth authentication. diff --git a/docs/deployment/running-server.mdx b/docs/deployment/running-server.mdx index 9bb8e544c..c10855345 100644 --- a/docs/deployment/running-server.mdx +++ b/docs/deployment/running-server.mdx @@ -240,7 +240,7 @@ if __name__ == "__main__": mcp.run(transport="http") # Health check at http://localhost:8000/health ``` -Custom routes are served by the same web server as your MCP endpoint. They're available at the root of your domain while the MCP endpoint is at `/mcp`. For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). +Custom routes are served by the same web server as your MCP endpoint. They're available at the root of your domain while the MCP endpoint is at `/mcp/`. For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). ## Alternative Initialization Patterns diff --git a/docs/deployment/server-configuration.mdx b/docs/deployment/server-configuration.mdx index c67d5ef1f..f9b0e4781 100644 --- a/docs/deployment/server-configuration.mdx +++ b/docs/deployment/server-configuration.mdx @@ -39,33 +39,30 @@ The `fastmcp.json` configuration answers three fundamental questions about your This conceptual model helps you understand the purpose of each configuration section and organize your settings effectively. The configuration file maps directly to these three concerns: -`source` is the *where*, `environment` the *what*, and `deployment` the *how*: - ```json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { - "type": "filesystem", + // WHERE: Location of your server code + "type": "filesystem", // Optional, defaults to "filesystem" "path": "server.py", "entrypoint": "mcp" }, "environment": { - "type": "uv", + // WHAT: Environment setup and dependencies + "type": "uv", // Optional, defaults to "uv" "python": ">=3.10", "dependencies": ["pandas", "numpy"] }, "deployment": { + // HOW: Runtime configuration "transport": "stdio", "log_level": "INFO" } } ``` -Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. Both `type` fields shown above are optional too, defaulting to `"filesystem"` and `"uv"` respectively. - -<Warning> -`fastmcp.json` is parsed as strict JSON, so it accepts no comments or trailing commas. -</Warning> +Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. ### JSON Schema Support @@ -232,10 +229,9 @@ Environment variables are included in this section because they're runtime confi <Expandable title="Deployment Fields"> <ParamField body="transport" type="string" default="stdio"> - Protocol for client communication. `"http"` and `"streamable-http"` both select FastMCP's Streamable HTTP transport: + Protocol for client communication: - `"stdio"`: Standard input/output for desktop clients - - `"http"`: Network-accessible Streamable HTTP server - - `"streamable-http"`: Explicit alias for Streamable HTTP + - `"http"`: Network-accessible HTTP server - `"sse"`: Server-sent events </ParamField> @@ -245,12 +241,12 @@ Environment variables are included in this section because they're runtime confi - `"0.0.0.0"`: All network interfaces </ParamField> - <ParamField body="port" type="integer" default="8000"> - Port number for HTTP transport. If omitted, FastMCP uses the server runtime default. + <ParamField body="port" type="integer" default="3000"> + Port number for HTTP transport. </ParamField> - <ParamField body="path" type="string" default="/mcp"> - URL path for the MCP endpoint when using HTTP transport. The default is `/mcp` for Streamable HTTP and `/sse` for SSE. + <ParamField body="path" type="string" default="/mcp/"> + URL path for the MCP endpoint when using HTTP transport. </ParamField> <ParamField body="log_level" type="string" default="INFO"> @@ -400,20 +396,20 @@ This flag tells FastMCP: "I already have the source code, skip any download/clon Note: For filesystem sources (local Python files), this flag has no effect since they don't require preparation. -The configuration file works with server-loading commands that explicitly accept FastMCP config files: +The configuration file works with all FastMCP commands: - **`run`** - Start the server in production mode -- **`dev inspector`** - Launch with the Inspector UI for development +- **`dev`** - Launch with the Inspector UI for development - **`inspect`** - View server capabilities and configuration -- **`install`** - Install to Claude Desktop, Cursor, or another MCP client +- **`install`** - Install to Claude Desktop, Cursor, or other MCP clients -`run`, `dev inspector`, and `inspect` search the current directory for a file named exactly `fastmcp.json` when you don't pass a file argument, so you can navigate to your project directory and run `fastmcp run` to start your server with all its configured settings. `install` requires an explicit path to the config file — it never searches. +When no file argument is provided, FastMCP searches the current directory for `fastmcp.json`. This means you can simply navigate to your project directory and run `fastmcp run` to start your server with all its configured settings. ### CLI Override Behavior Command-line arguments take precedence over configuration file values, allowing ad-hoc adjustments without modifying the file: ```bash -# Config specifies port 8000, CLI overrides to 8080 +# Config specifies port 3000, CLI overrides to 8080 fastmcp run fastmcp.json --port 8080 # Config specifies stdio, CLI overrides to HTTP @@ -438,7 +434,7 @@ You can use different configuration files for different environments: - `prod.fastmcp.json` - Production settings - `test_fastmcp.json` - Test configuration -Only a file named exactly `fastmcp.json` is auto-detected when you omit the path. Other FastMCP configuration files can use any `.json` name, but you must pass them explicitly. +Any file with "fastmcp.json" in the name is recognized as a configuration file. ## Examples @@ -475,7 +471,7 @@ A configuration optimized for local development: "type": "uv", "python": "3.12", "dependencies": ["fastmcp[dev]"], - "editable": ["."] + "editable": "." }, // HOW should it run? "deployment": { @@ -514,7 +510,7 @@ A production-ready configuration with full dependency management: "transport": "http", "host": "0.0.0.0", "port": 3000, - "path": "/api/mcp", + "path": "/api/mcp/", "log_level": "INFO", "env": { "ENV": "production", diff --git a/docs/development/contributing.mdx b/docs/development/contributing.mdx index e03ec37c7..c8772765a 100644 --- a/docs/development/contributing.mdx +++ b/docs/development/contributing.mdx @@ -134,7 +134,7 @@ Tests are documentation that shows how features work. Good tests give reviewers uv run pytest tests/server/ -v # Run all tests before submitting PR -uv run pytest -n auto +uv run pytest ``` Every new feature needs tests. See the [Testing Guide](/development/tests) for patterns and requirements. @@ -166,7 +166,7 @@ just api-ref-all #### Before Submitting -1. **Run all checks**: `uv run prek run --all-files && uv run pytest -n auto` +1. **Run all checks**: `uv run prek run --all-files && uv run pytest` 2. **Keep scope small**: One feature or fix per PR 3. **Write clear description**: Your PR description becomes permanent documentation 4. **Update docs**: Include documentation for API changes diff --git a/docs/development/releases.mdx b/docs/development/releases.mdx index f537703e2..331fd810c 100644 --- a/docs/development/releases.mdx +++ b/docs/development/releases.mdx @@ -53,8 +53,8 @@ We expect this exemption to last through at least the 2.12.x and 2.13.x release Pin to exact versions: ``` -fastmcp==4.0.0 # Good -fastmcp>=4.0.0 # Bad - will install breaking changes +fastmcp==2.11.0 # Good +fastmcp>=2.11.0 # Bad - will install breaking changes ``` ## Creating Releases @@ -65,7 +65,7 @@ Our release process is intentionally simple: 2. Generate release notes automatically, and curate or add additional editorial information as needed 3. GitHub releases automatically trigger PyPI deployments -Current-major releases target `main`. Maintenance releases target their release branch, such as `release/3.x` for 3.x patches and `release/2.x` for 2.x patches. Stable releases from `main` open a PR that syncs the release commit to `published-docs` after PyPI publishing succeeds; merging that PR publishes the live docs. Prereleases skip the automatic PR and use the same PR-based sync when their docs are ready to publish. Maintenance releases publish packages and GitHub release notes without repointing the live docs branch. +Current-major releases target `main`. Maintenance releases target their release branch, such as `release/3.x` for 3.x patches and `release/2.x` for 2.x patches. Stable releases from `main` update the `published-docs` branch after PyPI publishing succeeds; maintenance releases publish packages and GitHub release notes without repointing the live docs branch. This automation lets maintainers focus on code quality rather than release mechanics. diff --git a/docs/development/tests.mdx b/docs/development/tests.mdx index 37760e7aa..d3ade170c 100644 --- a/docs/development/tests.mdx +++ b/docs/development/tests.mdx @@ -14,7 +14,7 @@ Good tests are the foundation of reliable software. In FastMCP, we treat tests a ```bash # Run all tests -uv run pytest -n auto +uv run pytest # Run specific test file uv run pytest tests/server/test_auth.py @@ -26,7 +26,7 @@ uv run pytest --cov=fastmcp uv run pytest -m "not integration" # Skip tests that spawn processes -uv run pytest -m "not integration and not client_process and not subprocess_heavy" +uv run pytest -m "not integration and not client_process" ``` Tests should complete in under 1 second unless marked as integration tests. This speed encourages running them frequently, catching issues early. @@ -61,40 +61,6 @@ async def test_stdio_transport(): assert result.content[0].text == "test" ``` -A third marker, `subprocess_heavy`, exists specifically for Windows CI stability. See [Windows CI and Test Parallelism](#windows-ci-and-test-parallelism) below for when to use it and why it exists. - -### Windows CI and Test Parallelism - -Windows CI ran the unit suite serially for months. [#2715](https://github.com/PrefectHQ/fastmcp/pull/2715) tried enabling `pytest-xdist` parallelism there in December 2025; [#2726](https://github.com/PrefectHQ/fastmcp/pull/2726) reverted it the next day because "Windows tests continue to fail with intermittent worker crashes." [#4554](https://github.com/PrefectHQ/fastmcp/pull/4554) re-enabled it after removing most of the subprocess pressure that caused those crashes, taking the Windows unit step from roughly 460s to 175s. - -That pressure came from three sources, all addressed by #4554: most HTTP tests moved in-process via `asgi_client` instead of binding real sockets, stdio lifecycle tests spawn a minimal stdlib responder (`tests/client/minimal_stdio_server.py`, ~0.03s to start) instead of a subprocess that runs `import fastmcp` (~0.7s), and roughly 80 real `sleep()` calls became deterministic waits on the condition each test actually cared about. Fewer, cheaper subprocesses competing under parallel workers left fewer chances for a worker to die. - -#### The `subprocess_heavy` marker - -One class of test still spawns a full Python interpreter that imports FastMCP — checking that a bare install doesn't need optional dependencies, or that a decorator works from a fresh process. Each spawn pays a full interpreter's startup and memory footprint, and a 2-core Windows runner already running 2 xdist workers has little headroom left to absorb that. These tests carry `@pytest.mark.subprocess_heavy` and run in the existing serial `client_process` CI step instead of alongside the parallel workers — `.github/actions/run-pytest/action.yml` routes `client_process or subprocess_heavy` to that step (`MAX_PROCS=0`) and excludes both markers from the parallel unit step. - -If a test runs `subprocess.run([sys.executable, "-c", ...])`, or otherwise starts a fresh interpreter that imports `fastmcp`, mark it `subprocess_heavy`. A subprocess that runs a minimal stdlib script with no FastMCP import doesn't need the marker — it's the interpreter startup and import that's expensive, not the subprocess itself. - -#### This is a mitigation, not a proof - -There is no root-cause diagnosis behind this fix, only a plausible one. During validation, one Windows run genuinely crashed a worker on `test_fastmcp_imports_without_legacy_httpx` — a fresh-interpreter test — with pytest-xdist reporting `worker 'gw1' crashed while running '...'` after execnet's channel saw `ConnectionResetError: [WinError 10054]`. Nothing in that log says *why* the worker died: memory exhaustion, handle exhaustion, and some Windows-specific `subprocess`/`execnet` interaction are all still consistent with what was observed. Marking the fresh-interpreter tests `subprocess_heavy` made the crash stop recurring, but "it stopped" is not the same as "we know why." - -Treat the next Windows worker crash as a test of this diagnosis. **If it lands on a test that is not a fresh-interpreter spawner, the `subprocess_heavy` theory was wrong** — the real problem is subprocess-under-xdist on Windows more generally, and isolating one marker's worth of tests was never going to fix that. The fallback is one conditional back in `run-pytest/action.yml`, restoring the pre-#4554 behavior: - -```bash -PARALLEL_FLAGS="" -if [ "$MAX_PROCS" != "0" ] && [ "${{ runner.os }}" != "Windows" ]; then - PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal" -fi -``` - -#### Two traps that aren't Windows-specific - -Two test-authoring bugs surfaced while validating this change. Neither is about Windows or parallelism, but both are worth watching for anywhere a real `sleep()` gets replaced with a wait: - -- **Match the wait condition to the assertion.** A test waited for "any callback fired," then asserted that a `completed` callback existed. That races, because an earlier `working` notification satisfies the wait before the `completed` one arrives. A deterministic wait is only as good as the condition it waits on — wait for the thing you actually assert. -- **Don't assert on incidental timing.** A crash-recovery test asserted "at least one concurrent request fails" while a subprocess restarts, which quietly depended on the restart being slow. Once restart got faster, recovery could beat every in-flight request and the test started failing because the behavior *improved*. Assert the invariant instead: no hang, and no result served by the dead process. - ## Writing Tests @@ -333,19 +299,22 @@ async def test_database_tool(): ### Testing Network Transports -In-memory testing covers most unit testing needs, but some behavior only exists over HTTP: middleware, authentication, session management, header handling, and SSE streaming. To test those, serve your server over HTTP with `asgi_client`. +While in-memory testing covers most unit testing needs, you'll occasionally need to test actual network transports like HTTP or SSE. FastMCP provides two approaches: in-process async servers (preferred), and separate subprocess servers (for special cases). -#### Testing Over HTTP +#### In-Process Network Testing (Preferred) -<VersionBadge version="3.5.0" /> +<VersionBadge version="2.13.0" /> -`asgi_client` builds your server's real Starlette app, starts its lifespan, and hands you a connected `Client` that talks to it over the full HTTP stack. The one thing it skips is the socket: requests are dispatched straight into the ASGI application on the current event loop, so there is no port to bind, no uvicorn to start, and no connection to negotiate. Everything else — middleware, authentication, session management, SSE framing — runs exactly as it does in production. +For most network transport tests, use `run_server_async` as an async context manager. This runs the server as a task in the same process, providing fast, deterministic tests with full debugger support: ```python -from fastmcp import FastMCP -from fastmcp.utilities.tests import asgi_client +import pytest +from fastmcp import FastMCP, Client +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.utilities.tests import run_server_async def create_test_server() -> FastMCP: + """Create a test server instance.""" server = FastMCP("TestServer") @server.tool @@ -354,89 +323,26 @@ def create_test_server() -> FastMCP: return server -async def test_greet_over_http(): - async with asgi_client(create_test_server()) as client: - greeting = await client.call_tool("greet", {"name": "World"}) - assert greeting.data == "Hello, World!" -``` - -Pass `transport="sse"` to exercise the SSE app instead of streamable HTTP, `path=` to serve on a custom path, `headers=` and `auth=` to configure the client's requests, and any other keyword argument to configure the `Client` itself. - -```python -async def test_tenant_header_is_visible_to_tools(): - async with asgi_client( - create_test_server(), - headers={"X-Tenant-ID": "acme"}, - timeout=5, - ) as client: - await client.list_tools() -``` - -#### Sharing One Server Across Tests - -When several tests share a server but each needs its own client, use `asgi_server` in a fixture. It yields an `ASGIServer`, whose `client()` method produces a fresh client — with its own session — on demand. - -```python -import pytest -from fastmcp import FastMCP -from fastmcp.utilities.tests import ASGIServer, asgi_server - @pytest.fixture -async def http_server(): - server = FastMCP("TestServer") +async def http_server() -> str: + """Start server in-process for testing.""" + server = create_test_server() + async with run_server_async(server) as url: + yield url - @server.tool - def greet(name: str) -> str: - return f"Hello, {name}!" +async def test_http_transport(http_server: str): + """Test actual HTTP transport behavior.""" + async with Client( + transport=StreamableHttpTransport(http_server) + ) as client: + result = await client.ping() + assert result is True - async with asgi_server(server) as running_server: - yield running_server - -async def test_greet(http_server: ASGIServer): - async with http_server.client() as client: greeting = await client.call_tool("greet", {"name": "World"}) assert greeting.data == "Hello, World!" - -async def test_sessions_are_isolated(http_server: ASGIServer): - async with ( - http_server.client(mode="legacy") as first, - http_server.client(mode="legacy") as second, - ): - assert await first.ping() is True - assert await second.ping() is True ``` -Sessions belong to the handshake era of the MCP protocol, and so does `ping`, so a test that is about session behavior pins `mode="legacy"`. Every keyword argument `client()` doesn't consume itself is passed straight to `Client`. See [protocol negotiation](/clients/client#protocol-negotiation). - -For assertions about raw HTTP — status codes, response headers, metadata endpoints — `http_client()` returns an `httpx.AsyncClient` bound to the same app. Because nothing is listening on the network, this is the only way to make raw requests; a plain `httpx.AsyncClient()` cannot reach the server. - -```python -async def test_unauthenticated_request_is_rejected(http_server: ASGIServer): - async with http_server.http_client() as http: - response = await http.post(http_server.url, json={"jsonrpc": "2.0", "id": 1}) - assert response.status_code in (400, 401) -``` - -If you need to build the client transport yourself, `transport()` returns a `StreamableHttpTransport` or `SSETransport` already wired to the in-process app. - -#### Testing on a Real Port - -<VersionBadge version="2.13.0" /> - -`run_server_async` starts a real uvicorn server on a real TCP port as a task in the current process and yields its URL. Reach for it only when the subject of the test is the network itself — real sockets, TLS, or a server that must be reachable by something other than an in-process client. - -```python -from fastmcp import FastMCP, Client -from fastmcp.utilities.tests import run_server_async - -async def test_server_binds_a_real_port(): - server = FastMCP("TestServer") - - async with run_server_async(server) as url: - assert url.startswith("http://127.0.0.1:") - async with Client(url) as client: - assert await client.list_tools() == [] -``` +The `run_server_async` context manager automatically handles server lifecycle and cleanup. This approach is faster than subprocess-based testing and provides better error messages. #### Subprocess Testing (Special Cases) @@ -469,8 +375,8 @@ async def test_http_transport(http_server: str): async with Client( transport=StreamableHttpTransport(http_server) ) as client: - tools = await client.list_tools() - assert "greet" in [tool.name for tool in tools] + result = await client.ping() + assert result is True ``` The `run_server_in_process` utility handles server lifecycle, port allocation, and cleanup automatically. Use this only when subprocess isolation is truly necessary, as it's slower and harder to debug than in-process testing. FastMCP uses the `client_process` marker to isolate these tests in CI. diff --git a/docs/v3/development/v3-notes/auth-provider-env-vars.mdx b/docs/development/v3-notes/auth-provider-env-vars.mdx similarity index 100% rename from docs/v3/development/v3-notes/auth-provider-env-vars.mdx rename to docs/development/v3-notes/auth-provider-env-vars.mdx diff --git a/docs/v3/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx similarity index 99% rename from docs/v3/development/v3-notes/v3-features.mdx rename to docs/development/v3-notes/v3-features.mdx index 3d656248a..87b8ff762 100644 --- a/docs/v3/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -444,7 +444,7 @@ async def dashboard(ctx: Context) -> dict: **Future phases** will add a component DSL for building UIs declaratively, an in-repo renderer, and a `FastMCPApp` class. -Implementation: `fastmcp_slim/fastmcp/server/apps.py` (models and constants), with integration points in `server.py` (decorator parameters), `low_level.py` (extension advertisement), and `context.py` (`client_supports_extension` method). +Implementation: `fastmcp_slim/fastmcp/apps/config.py` (models and constants), with integration points in `server.py` (decorator parameters), `low_level.py` (extension advertisement), and `context.py` (`client_supports_extension` method). --- @@ -1205,8 +1205,8 @@ When `list_page_size` is set, `tools/list`, `resources/list`, `resources/templat ```python async with Client(server) as client: result = await client.list_tools_mcp() - while result.nextCursor: - result = await client.list_tools_mcp(cursor=result.nextCursor) + while result.next_cursor: + result = await client.list_tools_mcp(cursor=result.next_cursor) ``` Documentation: [Pagination](/servers/pagination) @@ -1426,7 +1426,7 @@ Prompt functions now use `Message` instead of `mcp.types.PromptMessage`: ```python # v2.x -from mcp.types import PromptMessage, TextContent +from fastmcp.types import PromptMessage, TextContent @mcp.prompt def my_prompt() -> PromptMessage: diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx new file mode 100644 index 000000000..7bd6c8b64 --- /dev/null +++ b/docs/development/v4-notes/change-register.mdx @@ -0,0 +1,386 @@ +--- +title: Change Register +--- + +This is the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), organized by subsystem. It doubles as a review lens: take one subsystem, read its claimed changes, and verify each against the diff. + +Each entry is tagged **Absorbed** (public surface unchanged), **Bridged** (shim keeps old code working, usually warning), **Breaking** (user code must change), or **Deprecated** (works, warns, slated for removal). See the [overview](/development/v4-notes/index) for what each disposition means. + +**Empirical validation (WS2 upgrade reality-check).** The register's compatibility claims are verified, not predicted. Running unchanged 3.x-era code against this branch, all 11 upgrade scenarios pass or warn — the only failures are the two predicted breaks, user `mcp.types` imports and positional `McpError(ErrorData(...))` construction. Cross-version wire interop between a 3.4.3 peer and this branch is bidirectionally clean across 9 operations (3.4.3 client ↔ v4 server and v4 client ↔ 3.4.3 server over HTTP). All 25 `_ALIASES` bridge entries warn correctly with actionable messages. + +## Environment + +### Dependency floors: pydantic >= 2.12, Starlette >= 1.0 — Breaking (environment) + +The SDK v2 raises FastMCP's dependency floors. Projects pinning an older pydantic (e.g. `2.11.*`) hit an unsatisfiable-resolution error at install time and must bump their pin; unpinned projects get pydantic upgraded silently. The server extra floors Starlette at `>=1.0.1` — modern FastAPI (0.11x+) already runs Starlette 1.x, so coexistence is clean (verified with FastAPI 0.138.2); only very old FastAPI pinned below Starlette 1.0 conflicts. Both are documented in the [upgrade guide's Environment requirements](/getting-started/upgrading/from-fastmcp-3#environment-requirements). + +*Verify:* `fastmcp_slim/pyproject.toml` (`pydantic[email]>=2.12.0` core, `starlette>=1.0.1` server extra); WS2 environment-upgrade scenario. + +## Types and imports + +The SDK v2 split protocol types into a standalone `mcp_types` package and renamed every field from camelCase to snake_case. This is the single largest source of user-facing change, and FastMCP absorbs nearly all of it. + +### `mcp.types` split into `mcp_types` — Breaking (by omission) + +The `mcp.types` module no longer exists. Any `from mcp.types import X` or `import mcp.types` in user code raises `ImportError`. This is the one import change users cannot avoid. + +*Verify:* `fastmcp_slim/fastmcp/types.py`, and grep the diff for the doc migration `from mcp.types import` → `from fastmcp.types import` (30 sites). + +### `fastmcp.types` is the stable home — Bridged + +FastMCP re-exports the protocol types users are most likely to touch from `fastmcp.types`, sourced from `mcp_types` (the `mcp` root package lacks most of them): + +```python +from fastmcp.types import TextContent, Tool, ToolAnnotations, ErrorData +``` + +The re-export set is deliberately limited to names that trace to a documented user import: `TextContent`, `ImageContent`, `AudioContent`, `EmbeddedResource`, `ResourceLink`, `ContentBlock`, `Tool`, `Resource`, `ResourceTemplate`, `Prompt`, `PromptMessage`, `CallToolResult`, `GetPromptResult`, `ReadResourceResult`, `TextResourceContents`, `BlobResourceContents`, `SamplingMessage`, `CreateMessageResult`, `SamplingCapability`, `Root`, `ErrorData`, `Completion`, `Annotations`, `ToolAnnotations`, `Icon`, `ToolResultContent`, plus the pre-existing `Textarea`. Notification and request wrapper types (e.g. `ToolListChangedNotification`) are not re-exported — import those from `mcp_types` directly. + +*Verify:* `fastmcp_slim/fastmcp/types.py` `__all__`. + +### camelCase field reads are bridged — Bridged (deprecated) + +Objects FastMCP hands back — results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to sampling and elicitation handlers — are SDK v2 objects with snake_case fields. A compatibility bridge installed at import time routes the old camelCase names to their snake_case fields, warning once per read: + +```python +from fastmcp import Client + + +async def read_schema(): + async with Client("my_mcp_server.py") as client: + tools = await client.list_tools() + return tools[0].inputSchema # works, warns; prefer .input_schema +``` + +The bridged fields are exactly those users read, data-driven from an `_ALIASES` table: `inputSchema`/`outputSchema` (Tool); `mimeType` (Resource, ResourceTemplate, TextResourceContents, BlobResourceContents, ImageContent, AudioContent) and `uriTemplate` (ResourceTemplate); `isError`/`structuredContent` (CallToolResult); `hasMore` (Completion); `serverInfo`/`protocolVersion` (InitializeResult); `nextCursor`/`resourceTemplates` (List\*Result); `systemPrompt`/`maxTokens`/`stopSequences`/`modelPreferences`/`toolChoice` (CreateMessageRequestParams); `requestedSchema` (ElicitRequestFormParams). WS2 verified all 25 alias entries warn correctly with actionable messages. + +*Verify:* `fastmcp_slim/fastmcp/_compat.py` (the `_ALIASES` table and `install()`). + +### The bridge is a genuine runtime toggle — Absorbed (post-review fix) + +The bridge properties install unconditionally, and each getter reads the live `mcp_camelcase_compat` setting on every access: warn-and-return when enabled, raise `AttributeError` when disabled. An earlier version installed the bridge once at import, so flipping the setting afterward did nothing — commit `d9659453` fixed this so the toggle works at runtime: + +```python +import fastmcp + +fastmcp.settings.mcp_camelcase_compat = False # now takes effect immediately +``` + +The setting is documented in [Settings](/more/settings) as `FASTMCP_MCP_CAMELCASE_COMPAT`. + +*Verify:* `fastmcp_slim/fastmcp/settings.py` (setting), `fastmcp_slim/fastmcp/_compat.py` (per-read gate), commit `d9659453`. + +### `mcp-types` is now a core slim dependency — Absorbed (post-review fix) + +Bare `import fastmcp` loads `mcp_types` via `_sdk_patches` and `_compat`, so a bare `fastmcp-slim` install (without the `[mcp]` extra) hit `ModuleNotFoundError`. Because `mcp-types` only pulls `pydantic` and `typing-extensions` (already core), it was promoted to a core dependency while the full `mcp` SDK stays in the `[mcp]` extra. + +*Verify:* `fastmcp_slim/pyproject.toml` (`mcp-types==2.0.0b1` in core dependencies), commit `e16ffad4`. + +### `McpError` is an alias; construction changed — Bridged (catch) / Breaking (construct) + +`fastmcp.exceptions.McpError` is a plain alias of the SDK's `MCPError` — a plain alias, not a subclass, so `except McpError` still catches SDK-raised errors and `err.error.code` still reads: + +```python +from fastmcp.exceptions import McpError + +try: + ... +except McpError as err: + print(err.error.code) # unchanged +``` + +Construction is the one unavoidable behavior break. The v1 pattern of wrapping an `ErrorData` positionally raises `TypeError` under v2; construct with keywords instead: + +```python +from fastmcp.exceptions import McpError + +# Before (raises TypeError under SDK v2): +# raise McpError(ErrorData(code=-32000, message="Client not supported")) + +raise McpError(code=-32000, message="Client not supported") +``` + +*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`McpError = MCPError`). + +## Server core + +The SDK v2 rewrote the server request-handling model. FastMCP's handler layer is the most heavily rewritten part of the migration, but the public server API is unchanged. + +### Handler adapters — Absorbed + +Handlers are now registered by method string via `add_request_handler(method, params_type, handler)`, take a uniform `(ctx, params)` signature, and return the **bare** result model (no `ServerResult` wrapper). FastMCP's `_setup_handlers` builds one thin adapter per method (`tools/list`, `tools/call`, `resources/read`, `prompts/get`, `logging/setLevel`, …) that binds the request context, adapts params to the existing handler body, and returns the bare result. The v1 decorator overrides and `_wrap_list_handler` are deleted. + +*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (462 lines changed), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`. + +### FastMCP-owned request context — Absorbed + +The SDK's `request_ctx` ContextVar is gone; the SDK passes context to handlers as an argument only. FastMCP owns its own `fastmcp_request_ctx` ContextVar, set at the top of every adapter. It stores a FastMCP-owned `FastMCPRequestContext` wrapper rather than the raw SDK context, because the raw `ServerRequestContext.meta` is a bare `TypedDict` carrying only `progress_token` — the full `_meta` block (which holds `_meta.fastmcp.version` and the distributed-trace parent) has to be lifted out of the raw params dict. `Context.request_context` and its consumers (`report_progress`, `session_id`, telemetry trace extraction, `get_http_request`) all read through the wrapper. + +*Verify:* `fastmcp_slim/fastmcp/server/dependencies.py`, `server/context.py`, `server/telemetry.py`. + +### `ServerMiddleware` bridge for `initialize` — Absorbed + +Server-side middleware is a new first-class SDK concept: `Server.middleware` is a list of `ServerMiddleware` composed around every request and notification, including `initialize`. FastMCP no longer subclasses `ServerSession` (the runner constructs it), so the old `MiddlewareServerSession._received_request` override is gone. A `FastMCPServerMiddleware` is appended to the SDK's middleware list (preserving the SDK's own OpenTelemetry middleware) and intercepts `initialize` to run FastMCP's middleware chain. The v2 seam is cleaner — `call_next(ctx)` returns the serialized result directly, so the old `capturing_respond` machinery is deleted. + +*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (`FastMCPServerMiddleware`). + +### Per-session state re-homed to the connection — Absorbed + +Because `ServerSession` is now per-request, per-session state can no longer live on the session object. The minimum logging level is re-homed to a FastMCP-side map keyed by session id (via `connection.session_id`), and `client_supports_extension` becomes a free function reading `session.client_params.capabilities`. + +*Verify:* `fastmcp_slim/fastmcp/server/low_level.py`, `server/context.py` (`_log_to_server_and_client`). + +### `extensions` capability read from the real field — Absorbed (post-review fix) + +SDK v2 declares `extensions` as a real field on `ClientCapabilities`, so a client sending `ClientCapabilities(extensions={...})` populates the field, not `model_extra`. `client_supports_extension` now reads `caps.extensions` first and falls back to `model_extra` only for legacy-serialized clients. + +*Verify:* commit `96ca0092`, `server/low_level.py` / `server/context.py`. + +### Task protocol and the `_sdk_patches` shim — Absorbed (with an upstream gap) + +The SEP-1686 task CRUD protocol (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`) is entirely FastMCP-owned — the SDK ships no task store. Task detection moves to a params field: `params.task is not None` on `CallToolRequestParams`, with `ttl` from `params.task.ttl`. The four task handlers port to `add_request_handler`. + +The SDK has a real gap here (see [Known Gaps](/development/v4-notes/known-gaps) and sdk-feedback #1): it ships the task result types but omits them from the method registries, so a background-task `tools/call` returning a `CreateTaskResult` fails validation. FastMCP installs a registry-widening shim in `_sdk_patches.py` that adds `CreateTaskResult` to the `tools/call` result union and registers the `tasks/*` rows. It is a temporary patch with a self-documented removal trigger. + +Resources and prompts have **no `task` field** on their params in b1, so task-augmented resource reads and prompt gets are not wire-expressible — a documented capability regression, tracked by xfails, not a bug FastMCP fixes. + +*Verify:* `fastmcp_slim/fastmcp/_sdk_patches.py`, `server/tasks/*`. + +### Single SERVER span per request — Absorbed (post-migration fix) + +SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each inbound request already emits a SERVER span. FastMCP emits its own richer SERVER span per request (with `fastmcp.*` and auth/session attributes), so a server with an OTel exporter installed would export **two** SERVER spans per request under different attribute conventions. `LowLevelServer.__init__` now drops the SDK's seeded `OpenTelemetryMiddleware` (matched by type, not position, leaving any other seeded middleware intact) and keeps FastMCP's spans. Inbound W3C trace-context extraction is unaffected — FastMCP's telemetry reads `traceparent` from `_meta` itself, so distributed traces still link client to server. Client-side is not double-counted: the SDK's `ClientSession` emits a low-level `MCP send <method>` CLIENT span that nests *under* FastMCP's high-level client span, a legitimate parent/child hierarchy rather than a duplicate. + +*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`. + +### Telemetry on by default, with an explicit off-switch — Absorbed + +FastMCP's OpenTelemetry instrumentation is on by default. Because FastMCP uses only the OpenTelemetry API, span creation is a no-op with negligible overhead (the API's `NonRecordingSpan`) unless the user configures an SDK and exporter — so being always-on costs nothing until you opt into collection. The new `FASTMCP_ENABLE_TELEMETRY` setting (`fastmcp.settings.enable_telemetry`, default `true`) is the explicit off-switch: set it to `false` and `get_tracer()` returns a genuine no-op tracer, so no FastMCP spans are created even when an SDK is configured. The off-switch governs FastMCP's own spans (all SERVER spans, plus FastMCP's high-level CLIENT span); the SDK's low-level `mcp-python-sdk` `MCP send <method>` CLIENT spans are governed by the user's OpenTelemetry SDK, not this setting. FastMCP's SERVER span now also carries `mcp.protocol.version` — the attribute the dropped SDK `OpenTelemetryMiddleware` set — restoring parity with the SDK's semantic conventions. + +*Verify:* `fastmcp_slim/fastmcp/settings.py` (`enable_telemetry`); `fastmcp_slim/fastmcp/telemetry.py` (`get_tracer` off-switch); `fastmcp_slim/fastmcp/server/telemetry.py` (`get_protocol_span_attributes`); `tests/server/telemetry/test_server_tracing.py::TestTelemetryEnabledByDefault`, `::TestProtocolVersionAttribute`. + +### Spec-correct error codes via a central translator — Breaking (wire error code) + +Resource-not-found responses from the core `resources/read` handler previously used `-32002`. SEP-2164 (and the SDK's own mcpserver, which maps `ResourceNotFoundError` → `INVALID_PARAMS`) makes this `-32602`. The per-adapter `MCPError(code=..., ...)` literals in `server/mixins/mcp_operations.py` are replaced by a single `fastmcp.exceptions.to_mcp_error()` translator that maps FastMCP's public exceptions to the `mcp_types` code constants (`NotFoundError`/`DisabledError`/`ValidationError` → `INVALID_PARAMS`, else `INTERNAL_ERROR`). Clients that string-matched on the old `-32002` for resource-not-found must switch to `-32602`; the human-readable message ("Resource not found: ...") is unchanged. The opt-in `ErrorHandlingMiddleware`, which has its own documented per-method-prefix code mapping, is intentionally left as-is. + +*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`to_mcp_error`); `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`; `tests/test_exceptions.py`. + +## Client + +The `fastmcp.Client` public API is preserved exactly. The client stays a wrapper around `mcp.ClientSession` in legacy/handshake mode; the first-class `mcp.client.Client` is deliberately not adopted in this PR. + +### Transports yield 2-tuples — Absorbed + +All SDK transports (`streamable_http_client`, `sse_client`, `stdio_client`) now yield a 2-tuple `(read, write)` instead of exposing a third `get_session_id` element. HTTP configuration flows through a caller-supplied `http_client=`. Only the tuple unpack changed on the FastMCP side. + +*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py`, `transports/sse.py`, `transports/stdio.py`. + +### Float timeouts; `timedelta` still accepted — Absorbed + +The SDK session and call timeouts are now plain floats. FastMCP's public `Client(timeout=...)` still accepts a `timedelta`, a plain float, or an int, normalizing through the existing `normalize_timeout_to_seconds` at the `SessionKwargs` chokepoint: + +```python +from datetime import timedelta + +from fastmcp import Client + +client = Client("my_mcp_server.py", timeout=timedelta(seconds=30)) # still works +client = Client("my_mcp_server.py", timeout=30.0) # also works +``` + +*Verify:* `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.read_timeout_seconds: float | None`), `client/client.py`. + +### `get_session_id` via header sniff — Bridged + +The SDK dropped `get_session_id` from the streamable-HTTP transport with no replacement (the SDK source has an author TODO acknowledging it breaks the Transport protocol). FastMCP reconstructs it by registering an httpx2 response event hook on the client it owns, capturing the `mcp-session-id` response header (httpx2 preserves httpx's `event_hooks` API). The removal trigger is the upstream TODO. + +*Verify:* `fastmcp_slim/fastmcp/client/transports/http.py` (`_capture_session_id`, `get_session_id`). + +### Pagination via `params=` — Absorbed + +The SDK's `cursor=` kwarg on `list_*` is gone; pagination now flows through `params=PaginatedRequestParams(cursor=...)`. FastMCP's public `cursor=` on the `list_*_mcp` methods is preserved and translated internally. + +*Verify:* `fastmcp_slim/fastmcp/client/mixins/{tools,resources,prompts}.py`. + +### OAuth `callback_handler` returns `AuthorizationCodeResult` — Breaking (advanced) + +The one OAuth break: a custom `callback_handler` must return an `AuthorizationCodeResult` (fields `code`, `state`, `iss`) instead of the old `tuple[str, str | None]`. Everything else in the OAuth surface — `OAuthClientProvider` kwargs, `TokenStorage`, `async_auth_flow` — is unchanged. + +*Verify:* `fastmcp_slim/fastmcp/client/auth/oauth.py`. + +### Notification dispatch unwrapped — Absorbed + +The client's notification handling was reworked for the v2 message model. Custom server-to-client notifications (like SEP-1686 `notifications/tasks/status`) are no longer tee'd to a user `message_handler` — the SDK routes them only through `NotificationBinding` (see sdk-feedback #8). FastMCP registers a binding so task-status updates reach the Task registry. + +*Verify:* `fastmcp_slim/fastmcp/client/messages.py`, `client/tasks.py`. + +### `SDKServer` alias — Absorbed (post-review rename) + +The in-memory transport resolves the low-level server per server type. The alias for the SDK's own `MCPServer` was renamed from the misleading `FastMCP1Server` / `FastMCP1x` to `SDKServer`, since it names the SDK v2 server, not a FastMCP 1.x object. + +*Verify:* commit `5c3b82e4`; `client/client.py`, `client/transports/memory.py`, `server/providers/proxy.py`, `cli/run.py`. + +### Proxy request-context stash — Absorbed (post-review fix) + +Proxy forwarding handlers stash the request context so a backend that issues a server-initiated request (list_roots/sampling/elicitation) can relay it back to the proxy's own client. This stash was initially applied only on the tool path; commit `1ac166bd` extended it to proxied resources, templates, and prompts. + +*Verify:* commit `1ac166bd`, `server/providers/proxy.py`. + +### Shared response cache via `KeyValueResponseCacheStore` — New + +The SDK's client response cache (SEP-2549) reads and writes through a pluggable `ResponseCacheStore`; the default is a per-client in-memory LRU. FastMCP adds `KeyValueResponseCacheStore`, an adapter over the same `AsyncKeyValue` key-value abstraction the event store and OAuth proxy already use, so a fleet of clients (e.g. proxy replicas) can share one Redis-backed response cache. Pass it via `CacheConfig(store=...)`; a custom store requires an explicit `partition` (SDK) and `target_id` (FastMCP). Results serialize through a type-tagged envelope validated against an allowlist of cacheable result models — an unknown tag is a cache miss, never an import-by-name — and each adapter owns its own collection so `clear()` never touches another tenant. + +```python +from fastmcp.client.caching import KeyValueResponseCacheStore +from mcp.client.caching import CacheConfig +from key_value.aio.stores.redis import RedisStore + +store = KeyValueResponseCacheStore(storage=RedisStore(url="redis://localhost")) +config = CacheConfig(store=store, partition="tenant-a", target_id="weather-api") +``` + +*Verify:* `fastmcp_slim/fastmcp/client/caching.py`, `tests/client/client/test_kv_response_cache.py`. + +## HTTP + +The maintainer asked whether FastMCP can now delete its custom HTTP app and let the SDK's `Server.streamable_http_app()` handle everything. The answer for this PR is **no** — every override earns its keep. Convergence is a v4 project gated on three upstream additions (see [Feature Program](/development/v4-notes/feature-program)). + +### Kept overrides — Absorbed + +Four overrides survive, each for a concrete reason: + +1. **Event-store session scoping.** The SDK hands every per-session transport the *same* `event_store` object, one stream-ID keyspace shared across sessions. FastMCP's `FastMCPStreamableHTTPSessionManager` returns a fresh `SessionScopedEventStore(shared, session_id=…)` per session, so resumability events don't leak across sessions. +2. **Lifespan reconciliation.** The SDK builder enters the bare lowlevel `Server.lifespan` (which yields `{}`). FastMCP drives its own `_lifespan_manager` — ref-counted for mounts, Ctrl-C-shielded, docket-aware. The SDK path silently skips all of it, so FastMCP sets the server lifespan to delegate to `_lifespan_manager` and lets the manager enter it once. +3. **Graceful transport termination.** FastMCP's lifespan `finally` drains the manager's server instances via `transport.terminate()` before task-group cancel, fixing the Uvicorn "returned without completing response" edge (#3025). The SDK just cancels. +4. **User ASGI middleware hook.** The SDK builder hardcodes an empty middleware list and only appends auth. FastMCP's `http_app(middleware=...)` and `RequestContextMiddleware` have nowhere to go in the SDK path. + +*Verify:* `fastmcp_slim/fastmcp/server/http.py`, `server/event_store.py`, `server/mixins/lifespan.py`. + +### DNS-rebinding ownership — Absorbed (security) + +FastMCP owns DNS-rebinding protection through its `HostOriginGuardMiddleware`, which is more expressive than the SDK's and is the documented surface. To avoid two allowlists double-blocking with confusing errors from two layers, FastMCP **always** disables the SDK's layer by passing `TransportSecuritySettings(enable_dns_rebinding_protection=False)` to the manager — both when FastMCP's protection is on (so they don't double-block) and when it's off (so the SDK's default-on flip can't silently re-enable it). + +*Verify:* `fastmcp_slim/fastmcp/server/http.py` (`enable_dns_rebinding_protection=False`, `HostOriginGuardMiddleware`). + +### httpx2 replaces httpx — Breaking (custom client/factory, typing) / Absorbed (everything else) + +SDK v2.0.0b2 replaces `httpx` + `httpx-sse` with [httpx2](https://pypi.org/project/httpx2/) (`>=2.5.0`), a next-generation httpx fork with built-in SSE. httpx2 is a near drop-in fork: the public API (`AsyncClient`, `Auth`, `Request`, `Response`, `Timeout`, `MockTransport`, exception hierarchy, `event_hooks`) matches httpx name-for-name. The SDK duck-types the client you hand it — `streamable_http_client(http_client=...)` and `sse_client(httpx_client_factory=...)` are type-hinted `httpx2.AsyncClient` with no `isinstance` gate — but the objects that cross into the SDK must be httpx2. + +FastMCP now uses **httpx2 exclusively** and no longer depends on `httpx`. Every FastMCP-owned HTTP path moves to httpx2: the client transports (`client/transports/{base,http,sse}.py`), client auth (`client/auth/{oauth,bearer}.py` — `BearerAuth`/`OAuth` subclass `httpx2.Auth`), the client-side exception-group handler (`utilities/exceptions.py`), the proxy's upstream client (`server/providers/proxy.py`), the `MCPConfig` client-auth field (`mcp_config.py`), **and** all the server-side code that the earlier seam pass had left on httpx — the ~15 server auth providers' upstream IdP calls, the OpenAPI provider, `from_openapi`/`from_fastapi`, `version_check`, `resources/types.py`, the SSRF download guard, and the `apps_dev` CLI. `httpx` is dropped from the `mcp` extra entirely (it may still arrive transitively via other libraries, but FastMCP never imports it). The ~170 `httpx_mock` calls across the security-critical server-auth test files are ported to a local httpx2-backed `httpx_mock` fixture (`tests/utilities/httpx2_mock.py`) that preserves the `add_response`/`add_exception`/`get_request(s)` API verbatim, so `pytest-httpx` is dropped too. + +User-visible deltas: + +- **Custom client factory / client.** `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, and `OAuth(httpx_client_factory=...)` factories must now return `httpx2.AsyncClient`; a custom `httpx.Auth` passed as `Client(auth=...)` should become `httpx2.Auth`. httpx2 is a drop-in fork, so the change is an import swap (`import httpx` → `import httpx2`). This is a typing break; at runtime a duck-compatible httpx client still satisfies the SDK, but mixing `httpx.Timeout`/`httpx.Auth` with an httpx2 client is unsupported. +- **OpenAPI client.** `FastMCP.from_openapi(client=...)` and `OpenAPIProvider(client=...)` are now type-hinted `httpx2.AsyncClient`. There is no `isinstance` gate, so an existing `httpx.AsyncClient` still works at runtime via duck-typing this release; the typing nudges you to httpx2. +- **TLS trust store.** httpx2 verifies TLS against the OS trust store via `truststore` (honoring `SSL_CERT_FILE`/`SSL_CERT_DIR` first) instead of the bundled certifi CA set. This now applies to **all** FastMCP HTTP, including server-auth upstream IdP calls — not just the client path. Corporate-CA and certifi-pinned setups may see different trust behavior. +- **Logger renames.** FastMCP HTTP now logs under `httpx2` and `httpcore2.*` (was `httpx`/`httpcore.*`). Anyone filtering FastMCP HTTP logs by logger name must update the names. + +The session-id header hook (below) works unchanged: httpx2 keeps httpx's `event_hooks` API. FastMCP's tool/resource/prompt handlers still map upstream 429/timeout errors to actionable `ToolError`/`ResourceError`; because a user's own tool may raise from either library, `server/server.py` catches both `httpx2` and (if installed) legacy `httpx` `HTTPStatusError`/`TimeoutException` via a defensive `try: import httpx` shim. + +*Verify:* `fastmcp_slim/pyproject.toml` (`mcp` extra lists only `httpx2`); no FastMCP source imports `httpx` except the documented defensive shim in `server/server.py`. + +## Protocol eras + +The SDK v2 serves multiple protocol eras from one server, and FastMCP formally embraces this. + +### Dual-era serving — Absorbed (supersedes "latest only") + +A single FastMCP server now handles clients across the protocol transition: the session-based handshake eras (through 2025-11-25) and the sessionless `2026-07-28` era (capability discovery via `server/discover`) simultaneously. This supersedes FastMCP's earlier "latest protocol only" stance. + +### Per-feature era matrix — Breaking (feature availability by era) + +The push-style Context features that require the server to call back into the client are unavailable on the sessionless `2026-07-28` era, because that era removes server-initiated requests (SEP-2577). The request/response features flow on every era. + +| Context feature | Session-based eras | `2026-07-28` (sessionless) | +| --- | --- | --- | +| `ctx.info` / logging notifications | Supported | Supported | +| Tools, resources, prompts, completions | Supported | Supported | +| `ctx.elicit` | Supported | Not yet — MRTR rewrite pending | +| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side | +| `ctx.list_roots` | Supported | Not yet — MRTR rewrite pending | +| Tasks (via the FastMCP client) | Supported | Not yet | + +Tools that rely on `ctx.elicit` or `ctx.list_roots` continue to work against clients on the session-based eras. Sampling is the exception: it is deprecated on every era and will not return on modern connections (see the Deprecated entry below). + +Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging keeps working on session-based connections per the matrix. `ctx.sample`/`ctx.sample_step` additionally emit a FastMCP-owned `FastMCPDeprecationWarning` (see below). The upgrade guide calls both out explicitly. + +Wire interop across the transition is verified: a 3.4.3 client against a v4 server and a v4 client against a 3.4.3 server are bidirectionally clean across 9 operations over HTTP (WS2). + +*Verify:* `docs/getting-started/upgrading/from-fastmcp-3.mdx` (the published matrix and SDK-warning note), `tests/server/test_protocol_eras.py`. + +### Sampling deprecated, era-gated — Deprecated + +`ctx.sample()` and `ctx.sample_step()` are deprecated and slated for removal in a future FastMCP release. Server-initiated sampling relies on the `createMessage` back-channel that SEP-2577 removed from the wire as of `2026-07-28`, and unlike elicitation it has no multi-round-trip replacement (the agentic loop would exhaust the round-trip budget). Both methods now emit a `FastMCPDeprecationWarning` once per process (gated on `settings.deprecation_warnings`), and on a `2026-07-28` connection they raise a clear `ToolError` before touching the wire. The client-side sampling handler infrastructure (anthropic/openai/google_genai) is retained for future MRTR work and is not deprecated. The migration is to call an LLM directly from your server rather than borrowing the client's model. + +The dead TODO at `server/context.py` (a background-task sampling relay that was never built) is removed: that relay is not being built, so the note is gone rather than left as a promise. + +*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`_warn_sampling_deprecated`, `_is_modern_protocol`, the `sample`/`sample_step` gates), `docs/servers/sampling.mdx` (deprecation banner), `tests/server/test_protocol_eras.py` (warning + era-gate tests). + +### Push-feature degradation quality — Resolved (was sdk-feedback #10) + +On a `2026-07-28` connection the degradation error used to differ by feature: `ctx.list_roots` raised a clear `NoBackChannelError`, while `ctx.elicit` / `ctx.sample` surfaced a bare "Method not found" because those methods attach a `related_request_id` and reach client dispatch before failing. FastMCP now era-gates `ctx.elicit` and `ctx.sample`/`ctx.sample_step` to raise a clear, era-aware `ToolError` before the wire ("server-initiated sampling is not available on MCP 2026-07-28 connections…" and "elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test. + +*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_sample_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gates). + +### Server-level cache hints (SEP-2549) — New (opt-in feature) + +A FastMCP server can emit SEP-2549 freshness hints so a caching client (`fastmcp.Client(cache=...)`) may reuse a response without a wire round-trip. Two constructor params carry it: `FastMCP(cache_ttl=300, cache_scope="public")`, where `cache_ttl` is in seconds and `cache_scope` is `"public"` or `"private"` (default `"private"` when a TTL is set). The hint is uniform by construction — one server-level value applies to every SDK-cacheable method (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, and `server/discover`) with no per-component surface and no aggregation. FastMCP does not hand-set the wire fields: it passes the hint through to the SDK low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on every cacheable result via `apply_cache_hint`, leaving any field a handler set explicitly untouched. `cache_ttl` must be positive, and a `cache_scope` without a `cache_ttl` is rejected at construction (a scope alone does not enable caching, since the client gates on the TTL's presence). Absent both params, no hint is emitted. Honoring is modern-only (the SDK client reads hints only at `2026-07-28`) and opt-in on the client, so a hinted server is inert unless the client passes `cache=`. + +*Verify:* `fastmcp_slim/fastmcp/server/caching.py` (`build_cache_hints`), `fastmcp_slim/fastmcp/server/server.py` (constructor params passed to `LowLevelServer(cache_hints=...)`), `tests/server/test_cache_hints.py` (unit validation + end-to-end interop with `fastmcp.Client(cache=True)`). + +### The xfail register — Known gap + +Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](/development/v4-notes/known-gaps) page. + +## Security + +FastMCP retains hardening that is not yet upstream and does not remove it during the migration. + +### Retained OAuth / DCR hardening — Absorbed + +FastMCP keeps its own DCR redirect-URI hardening (PRs #4419, #4408) regardless of the SDK's validation, which still accepts unsafe `javascript:`/`data:` redirect schemes at the model level (sdk-feedback #4). The streamable-HTTP DNS-rebinding protection above is a second retained security surface. + +*Verify:* recent commits `67527c1f` (block unsafe OAuth redirect schemes), `57a27992` (DNS rebinding), `cccb529f` (DCR redirect URI validation) on `main`. + +### Templated resource parameters are path-screened by default — Breaking (behavior) + +Every templated resource now has its extracted parameter values screened for path-traversal (`..` segments), absolute paths, and null bytes **before the handler runs** — on by default, at the server's read chokepoint, covering local and provider-sourced (mounted/proxied) templates alike. Previously these payloads reached handlers raw; a template whose parameter flowed into a filesystem path or upstream URL was exposed unless the author added their own check. A rejected read now surfaces a non-leaky "resource not found" error (`-32602`) and a debug log. + +The check is component-based, matching the SDK's `contains_path_traversal`: only a standalone `..` segment is traversal, so values that merely contain dots (`HEAD~3..HEAD`, `file.tar.gz`) and dotfiles (`.env`) still pass. This can break a template that legitimately accepts `..`-bearing or absolute values — exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security). + +*Verify:* `fastmcp_slim/fastmcp/resources/security.py` (`ResourceSecurity`), the screening block in `FastMCP.read_resource` (`fastmcp_slim/fastmcp/server/server.py`), and `tests/resources/test_resource_security.py`. + +## Removed in 4.0 + +Deprecations that warned in 3.x are removed in 4.0. Each entry below is a hard removal — the old surface raises `TypeError` / `AttributeError` rather than warning, unless noted otherwise. + +### Module and class shims + +- **`fastmcp.server.proxy`** (deprecated 3.0) — Breaking. Import proxy classes (`FastMCPProxy`, `ProxyClient`, etc.) from `fastmcp.server.providers.proxy` instead. +- **`fastmcp.server.openapi`** and its submodules (`server`, `components`, `routing`), including the **`FastMCPOpenAPI`** class (deprecated 3.0) — Breaking. Use `FastMCP` with an `OpenAPIProvider` from `fastmcp.server.providers.openapi` instead. +- **`fastmcp.experimental.server.openapi`** and **`fastmcp.experimental.utilities.openapi`** shims (deprecated 2.14) — Breaking. Import from `fastmcp.server.providers.openapi` and `fastmcp.utilities.openapi` respectively. +- **`fastmcp.server.apps`** and **`fastmcp.server.app`** shims (deprecated 3.2) — Breaking. Import from `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) instead. +- **`PromptToolMiddleware`** and **`ResourceToolMiddleware`** (deprecated 3.1) — Breaking. Use the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` instead. The non-deprecated `ToolInjectionMiddleware` base class is retained. +- **`StreamableHttpTransport(sse_read_timeout=...)`** (deprecated no-op) — Breaking. The parameter had no effect under the SDK v2 client; configure timeouts via `read_timeout_seconds` in `session_kwargs` or on the httpx2 client via `httpx_client_factory`. `SSETransport` still accepts `sse_read_timeout`. + +### `FastMCP` server methods and `mount()` kwargs + +The following `FastMCP` methods and parameters, deprecated since 3.0, are removed: + +- `FastMCP.as_proxy(...)` → `create_proxy(...)` (`from fastmcp.server import create_proxy`) +- `FastMCP.import_server(sub)` → `mount(sub)` +- `mount(prefix=...)` → `mount(namespace=...)` +- `mount(as_proxy=...)` — removed; mounts always invoke the child's lifespan and middleware, so the flag was already meaningless. To proxy a server, wrap it with `create_proxy()` before mounting. +- `FastMCP.add_tool_transformation(name, config)` → `add_transform(ToolTransform({name: config}))` +- `FastMCP.remove_tool_transformation(name)` — removed; it was a no-op that only warned (transforms are immutable once added). Use `server.disable(keys=[...])` to hide tools. +- `FastMCP.remove_tool(name)` → `mcp.local_provider.remove_tool(name)` + +The `_REMOVED_KWARGS` constructor shim (which raises helpful `TypeError`s for kwargs removed in 3.0) is retained through 4.0. + +### Tool and component parameters + +- **Tool-level `serializer` parameter** — removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, `TransformedTool.from_tool`, the OpenAPI `OpenAPITool`, and the `mcp_mixin` tool decorator. Return a `ToolResult` from your tool for full control over serialization instead (see [Custom Serialization](/servers/tools#custom-serialization)). The server-level `tool_serializer` constructor kwarg was already removed in 3.0. +- **Tool `exclude_args` parameter** — removed from the tool decorator and its plumbing (`ParsedFunction.from_function`, `Tool.from_function`, `mcp.tool()`). Use dependency injection with `Depends()` to hide parameters from the tool schema instead. +- **`decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode — removed. Decorators always return the original function with metadata attached; the object-returning machinery is gone. Access component objects through the server (e.g. `await mcp.get_tool("name")`) rather than the decorated function. +- **Component-import compatibility shims** — the `__getattr__` shims that re-exported `FunctionTool` / `ParsedFunction` / `tool` from `fastmcp.tools.tool`, `FunctionResource` / `resource` from `fastmcp.resources.resource`, and `FunctionPrompt` / `prompt` from `fastmcp.prompts.prompt` are removed. Import these from their canonical modules (`fastmcp.tools.function_tool`, `fastmcp.resources.function_resource`, `fastmcp.prompts.function_prompt`) instead. + +*Verify:* deletions of `fastmcp_slim/fastmcp/server/proxy.py`, `fastmcp_slim/fastmcp/server/openapi/`, `fastmcp_slim/fastmcp/experimental/server/openapi/`, `fastmcp_slim/fastmcp/experimental/utilities/openapi/`, `fastmcp_slim/fastmcp/server/apps.py`, `fastmcp_slim/fastmcp/server/app.py`; the removed classes in `fastmcp_slim/fastmcp/server/middleware/tool_injection.py`; the removed parameter in `fastmcp_slim/fastmcp/client/transports/http.py`; `fastmcp_slim/fastmcp/server/server.py`; `fastmcp_slim/fastmcp/tools/base.py`, `tools/function_tool.py`, `tools/tool_transform.py`, `tools/function_parsing.py`; `fastmcp_slim/fastmcp/settings.py`, `resources/function_resource.py`, `prompts/function_prompt.py`, and the local-provider decorators; `resources/base.py`, `prompts/base.py`. diff --git a/docs/development/v4-notes/feature-program.mdx b/docs/development/v4-notes/feature-program.mdx new file mode 100644 index 000000000..57501562a --- /dev/null +++ b/docs/development/v4-notes/feature-program.mdx @@ -0,0 +1,129 @@ +--- +title: Feature Program +--- + +The migration is the foundation. The forward v4 program is a sequence of post-merge PRs that build on it. Each feature below carries an explicit status: + +- **Designed** — the approach is settled and an API sketch exists; implementation has not started. +- **Planned** — the shape is agreed but design details remain open. +- **Not started** — identified as v4 scope, not yet designed. + +Code blocks marked as sketches show the *intended* API and do not resolve against the current tree. + +## Sampling: deprecate now, remove in 4.0 + +**Status: Designed.** + +Sampling is the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so this API cannot work on modern connections. Background-task sampling is already dead under v2 — a worker's back-channel is gone once the submitting request returns, and no sampling relay was ever built (sdk-feedback #9). + +The plan is Option A: **deprecate the push-sampling API now and remove it in the 4.0 release.** + +- Deprecate `ctx.sample` / `ctx.sample_step` and the server sampling module now. +- Era-gate them to raise a clear error on `2026-07-28` (this also fixes the opaque "Method not found" of sdk-feedback #10). +- Remove `ctx.sample`, `ctx.sample_step`, `server/sampling/`, `SamplingTool`, and structured-result sampling in 4.0. + +The migration story is honest: there is **no drop-in** on modern connections. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. That shift is the real answer, and it is why the removal justifies a major version. + +The client-side provider handlers (Anthropic, OpenAI, Google GenAI) are **retained** regardless: MRTR needs them to answer sampling input-requests from the client side. What is removed is the server-side push emitter, which the SDK never built for the modern era. + +In this PR, sampling still functions on the legacy eras. Users already see an SDK-level `MCPDeprecationWarning` on ordinary `ctx.sample` usage (the SDK deprecated the capability wire-side per SEP-2577, verified empirically by WS2), but FastMCP's own deprecation — warnings with migration guidance, plus the era-gating — lands as the first follow-up PR. + +## MRTR elicitation + +**Status: Designed. Flagship feature.** + +Elicitation survives the modern era, but only declaratively. The 2026 wire envelope still carries elicitation as a multi-round input-request (MRTR — multi-round tool result). Imperative `ctx.elicit` relies on the session back-channel, which is gone on `2026-07-28` foreground calls; on the modern era, elicitation is reachable only through a declarative resolver. + +The design does both, so the imperative DX survives where it can and a declarative surface covers the modern era: + +**1. Keep `ctx.elicit` as the primary imperative DX,** re-plumbed to be era-aware: legacy connections use the session elicit-form path; background tasks on any era use the existing Redis relay (the task's `input_required` status *is* the MRTR suspension boundary); foreground calls on `2026-07-28` raise a clear era-aware error pointing at the declarative form. + +**2. Add a declarative surface** in a new `fastmcp.elicitation` module — `Resolve`, `Elicit`, and `ElicitationResult` — thin wrappers over the SDK's resolver, wired into FastMCP's own tool layer (FastMCP tools do not inherit the SDK's auto-resolver wiring). + +The intended DX (sketch — the module does not exist yet): + +```python test="skip" +from typing import Annotated + +from pydantic import BaseModel + +from fastmcp import FastMCP, Context +from fastmcp.elicitation import Resolve, Elicit, ElicitationResult + +mcp = FastMCP("shipping") + + +class Address(BaseModel): + street: str + city: str + zip: str + + +async def ask_address(ctx: Context) -> Elicit[Address]: + return Elicit("Where should we ship this order?", Address) + + +@mcp.tool +async def create_shipment( + order_id: str, + address: Annotated[Address, Resolve(ask_address)], # unwrapped; decline -> ToolError +) -> str: + return f"Shipping {order_id} to {address.city}" + + +@mcp.tool +async def maybe_ship( + order_id: str, + address: Annotated[ElicitationResult[Address], Resolve(ask_address)], # full outcome +) -> str: + if address.action != "accept": + return "cancelled" + return f"Shipping {order_id} to {address.data.city}" + + +@mcp.tool(task=True) +async def slow_ship(ctx: Context) -> str: + # imperative ctx.elicit survives 2026 via the background-task relay + result = await ctx.elicit("Confirm address", Address) + if result.action == "accept": + return f"Shipping to {result.data.city}" + return "cancelled" +``` + +The registration path detects `Annotated[_, Resolve(...)]` parameters, builds resolver plans, and returns the SDK's `InputRequiredResult` instead of the tool body on the first round. The FastMCP client already dispatches input-requests through its elicitation callback; the follow-up work confirms the FastMCP client wrapper drives the input-required driver the way the SDK's own client does. + +The divergence between elicitation and sampling on 2026 comes down to one fact: the SDK built the server-side emitter for elicitation (`Elicit`/`Resolve`) and not for sampling. The wire carries all three input-request types and the client dispatches all three; only elicitation can produce one server-side. That is why elicitation survives 4.0 via MRTR and push-sampling does not. + +## Middleware on the SDK `ServerMiddleware` seam + +**Status: Planned.** + +The migration already routes `initialize` interception through the SDK's new `ServerMiddleware` seam via `FastMCPServerMiddleware`. The forward work is to lean into that seam more fully — moving more of FastMCP's request-lifecycle middleware onto the native SDK composition point rather than FastMCP-side wrappers, now that the SDK composes middleware around every request and notification. + +## First-class 2026 client + +**Status: Planned.** + +The migration keeps `fastmcp.Client` as a wrapper around `mcp.ClientSession` in legacy/handshake mode. The v4 client work adopts the SDK's first-class `mcp.client.Client`: a `mode='auto'` that negotiates the era, `discover()` for sessionless capability discovery, and the MRTR input-required driver so the client can answer multi-round elicitation and sampling input-requests. This is the client-side half of full `2026-07-28` support. + +This workstream also owns the server-side statelessness design holes — `ctx.session_id` / `set_state` round-tripping, task push and background elicitation, and stateful-proxy affinity — since all three turn on the same "what is a session without a session?" question. See [Statelessness on 2026-07-28](/development/v4-notes/known-gaps#statelessness-on-2026-07-28) for the full accounting. + +## Subscriptions, cache hints, extensions, OTel + +**Status: Not started.** + +A cluster of protocol features tracked for v4 once the core client and elicitation work lands: a `subscriptions/listen` surface backed by a subscription bus, resource cache hints, reconciliation of the `extensions` / MCP Apps capability advertisement across eras (the `extensions` capability is stripped at pre-2026 negotiated versions today — sdk-feedback #2), and the OpenTelemetry integration re-checked against the SDK's own OTel middleware. + +## SDK delegation, round two + +**Status: Planned (gated on upstream).** + +The real HTTP simplification is a v4 project, not this PR. FastMCP can collapse its `create_streamable_http_app` onto the SDK's `Server.streamable_http_app()` once upstream adds three things: + +1. per-session event-store scoping, +2. a user-middleware injection hook, +3. a lifespan hook. + +The payoff is not only less code — FastMCP would also inherit the SDK's session-owner credential enforcement, a security gain it lacks today. These are the three upstream feature requests to file (alongside the advisory dossier described in [Known Gaps](/development/v4-notes/known-gaps)). Until they land, the four HTTP overrides in the [Change Register](/development/v4-notes/change-register#http) stay. + +One latent capability worth surfacing on FastMCP's side: `session_idle_timeout` is accepted by the manager but never set by `create_streamable_http_app` — a one-line plumb if FastMCP wants to expose it. diff --git a/dev-docs/v4-notes/index.md b/docs/development/v4-notes/index.mdx similarity index 52% rename from dev-docs/v4-notes/index.md rename to docs/development/v4-notes/index.mdx index 282c37af2..1cbebfdcd 100644 --- a/dev-docs/v4-notes/index.md +++ b/docs/development/v4-notes/index.mdx @@ -4,9 +4,9 @@ title: v4.0 Development Notes This directory is the working map of FastMCP v4.0: the complete register of user-facing changes from the MCP Python SDK v2 migration ([PR #4437](https://github.com/PrefectHQ/fastmcp/pull/4437)), plus the forward v4 feature program. It plays three roles at once. -1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](change-register.md). -2. **A feature program.** The forward v4 work — sampling removal, multi-round-trip elicitation, the first-class 2026 client, a FastMCP-native extension API, the SEP-2663 background-tasks rebuild, and the SDK-delegation round-two convergence — now a mix of shipped, designed, and pending. Multi-round-trip guard tools (#4544), the client's `mode="auto"` default with a partial SDK-composition (#4572/#4574, full composition blocked upstream), the extension API (#4602), and background tasks on SEP-2663 (#4603) have shipped; sampling removal and SDK delegation remain ahead. Each carries an explicit status in the [Feature Program](feature-program.md). The shipped side — what a v4 deployment provides on the modern protocol today, including the complete server-side SEP-990 identity assertion implementation — is cataloged in [2026-07-28 Protocol Support](protocol-2026.md). -3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](known-gaps.md) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work. +1. **A change register.** Every user-visible change from the migration, organized by subsystem, with a note on how FastMCP handles it (absorbed, bridged, breaking, or deprecated) and where to find it in the diff. This is the [Change Register](/development/v4-notes/change-register). +2. **A feature program.** The forward v4 work — sampling removal, MRTR elicitation, the first-class 2026 client, and the SDK-delegation round-two convergence — each with an explicit status. This is the [Feature Program](/development/v4-notes/feature-program). +3. **A review lens.** Because the migration PR is too large to review line by line, the change register is organized so a reviewer can take one subsystem, read its claimed changes, and verify each against the diff. The [Known Gaps](/development/v4-notes/known-gaps) page collects the deliberate xfails and the upstream dependencies that gate the follow-up work. ## Why v4 exists @@ -16,34 +16,22 @@ FastMCP v4.0 is an engine swap. Three forces drive the major version: **Protocol version 2026-07-28.** The SDK v2 serves multiple protocol eras from one server. Alongside the session-based handshake eras, it introduces the sessionless `2026-07-28` era, which discovers capabilities through `server/discover` and removes server-initiated requests (SEP-2577). This formally supersedes FastMCP's earlier "latest protocol only" stance: a single server now works with clients across the protocol transition. -**Sampling and roots removed from the server API.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call, which takes `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` off the table. Rather than leave them half-working against old clients only, 4.0 removes them from the server API entirely — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump. Client-side handlers stay, because a modern client still has to answer a legacy server. +**Sampling removal.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call. That takes the push-shaped sampling API (`ctx.sample`, `ctx.sample_step`) off the table on modern connections. Rather than leave it half-working, v4 deprecates it now and removes it in the 4.0 release — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump. ## Release strategy The migration merges to `main` and development continues there with subsequent PRs. Releases follow the SDK's own beta timeline: -- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](known-gaps.md) page. +- **`main` carries the beta pins.** While the SDK is on `mcp==2.0.0b1` / `mcp-types==2.0.0b1`, `main` cuts **pre-releases** (`4.0.0b1`, `4.0.0b2`, …). No stable PyPI release goes out until `mcp 2.0.0` reaches GA — at which point the pins swap to the stable SDK and `4.0.0` ships. The pin-swap is a tracked checklist item on the [Known Gaps](/development/v4-notes/known-gaps) page. - **`release/3.x` is the maintenance line.** A `release/3.x` branch is cut from pre-merge `main`. It stays on the SDK v1 line, receives upstream security patches, and serves users who cannot move to the SDK v2 beta yet. -### Release codenames - -Following the pun-title convention (`v<version>: <pun>`), the v4 line runs a single "four" motif across the whole cycle, holding the headline name for the stable release the way v3 did ("Three at Last" for `3.0.0`, stage puns for its betas): - -| Release | Codename | The nod | -| --- | --- | --- | -| `4.0.0a1` (alpha) | **Fourst Contact** | _first contact_ — the first, cautious look at the new engine | -| `4.0.0a2` (alpha) | **Back and Fourth** | _back and forth_ — the second pass, where background tasks and stateless state land | -| `4.0.0b1` (beta) | **Fourgone Conclusion** | _foregone conclusion_ — once the MCP SDK went v2, v4 was inevitable | -| `4.0.0b2` (beta) | **Fourmidable** | _formidable_ — held in reserve for a second beta if one is needed | -| `4.0.0` (stable) | **Fast Fourward** | _fast forward_ — full speed onto the new foundation | - ## How to read the register -Each subsystem section in the [Change Register](change-register.md) tags its changes with one of four dispositions: +Each subsystem section in the [Change Register](/development/v4-notes/change-register) tags its changes with one of four dispositions: - **Absorbed** — the SDK changed underneath, but FastMCP's public surface is identical. Nothing for users to do. - **Bridged** — a compatibility shim keeps old code working, usually with a `FastMCPDeprecationWarning`. Users should migrate but are not forced to. - **Breaking** — user code must change. These are the headline migration items. - **Deprecated** — still works, warns now, slated for removal in a later release. -The user-facing summary of the migration lives in the published [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3) guide. These development notes are the exhaustive version behind it. +The user-facing summary of the migration lives in the published [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) guide. These development notes are the exhaustive version behind it. diff --git a/dev-docs/v4-notes/known-gaps.md b/docs/development/v4-notes/known-gaps.mdx similarity index 58% rename from dev-docs/v4-notes/known-gaps.md rename to docs/development/v4-notes/known-gaps.mdx index 0fb699029..3f2df1a9c 100644 --- a/dev-docs/v4-notes/known-gaps.md +++ b/docs/development/v4-notes/known-gaps.mdx @@ -6,11 +6,17 @@ The migration ships with a set of deliberate gaps: temporary shims, xfailed test ## The xfail register -Roughly forty `xfail` markers across the test tree name the SDK gaps and removed protocol surfaces they wait on. Re-running the suite against a new SDK beta surfaces which have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas — but the largest cluster is no longer a set of gaps to close. +Roughly forty `xfail` markers across the test tree are the built-in beta tracker. Each names the SDK gap it waits on, so re-running the suite against a new SDK beta surfaces exactly which gaps have closed (a strict xfail that starts passing fails the suite, prompting removal of the marker). They cluster in three areas. -**Task suite (`tests/server/tasks/`, `tests/client/tasks/`) — SEP-1686 wire layer being removed; engine rebuilt on SEP-2663.** The large majority. These cover the 2025 task protocol (SEP-1686), which left the core MCP spec and was reworked into the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP's SEP-1686 *wire* machinery (capability advertisement, the `tasks/get|result|list|cancel` handlers, the push notification/elicitation relay) is slated for removal, so the wire-protocol xfails disappear with the code they cover — they are not waiting on an SDK fix. The Docket/Redis *execution engine* underneath is not discarded: it is extracted into the planned `fastmcp-tasks` package and re-adapted to the SEP-2663 polling shape (see [Background Tasks (SEP-2663)](background-tasks.md)). The two SDK gaps these were originally filed against — **sdk-feedback #1** (SEP-1686 task result types omitted from the method registries) and **sdk-feedback #3** (no `task` field on `ReadResourceRequestParams` / `GetPromptRequestParams`) — are moot: they patched the SEP-1686 wire shape, which SEP-2663 replaces with a `CreateTaskResult` claimed on `tools/call`. The gap that matters for the rebuild is **sdk-feedback #2** (extensions capability stripped at pre-2026 negotiated versions) — it now gates a flagship feature and is escalated accordingly. +**Task suite (`tests/server/tasks/`, `tests/client/tasks/`).** The large majority. These trace to two SDK gaps: -**Protocol eras (`tests/server/test_protocol_eras.py`).** One remaining strict xfail, and it too is task-related: the v2 SDK high-level client exposes no `task=` parameter on `call_tool`, so a SEP-1686 task-augmented `tools/call` cannot be submitted through it. It resolves with the SEP-1686 wire-layer removal above; the SEP-2663 rebuild submits tasks by advertising the extension capability and claiming a `CreateTaskResult`, not through a `task=` params field. The earlier strict xfail for the `ctx.elicit` / `ctx.sample` "Method not found" degradation (sdk-feedback #10) is **gone** — the era-gating shipped in #4448 flipped it to a passing test. +- **sdk-feedback #1** — SEP-1686 ships the task result types but omits them from the method registries, so a task-augmented `tools/call` cannot complete validation. FastMCP's `_sdk_patches.py` registry-widening shim covers the common tool path; the xfails cover paths the shim intentionally does not paper over. +- **sdk-feedback #3** — `ReadResourceRequestParams` and `GetPromptRequestParams` have no `task` field, so task-augmented resource reads and prompt gets are not wire-expressible. The xfails in `test_task_resources.py`, `test_task_prompts.py`, `test_client_resource_tasks.py`, and `test_client_prompt_tasks.py` carry the reason "SDK v2 has no `task` field on GetPromptRequestParams / ReadResourceRequestParams." + +**Protocol eras (`tests/server/test_protocol_eras.py`).** Two strict xfails: + +- The strict xfail at `test_protocol_eras.py:319` maps directly to **sdk-feedback #10**: on `2026-07-28`, `ctx.elicit`/`ctx.sample` attach a `related_request_id` and surface a bare "Method not found" rather than a clear era-aware error. It stays strict until the SDK unifies the degradation path or FastMCP era-gates the calls. +- The strict xfail at `test_protocol_eras.py:400` covers the SDK's first-class high-level client (`mcp.client.Client`) and the sessionless driver that the FastMCP client does not yet adopt (see the [first-class 2026 client](/development/v4-notes/feature-program#first-class-2026-client) feature). **MCP Apps (`tests/test_apps.py`).** Two xfails tied to **sdk-feedback #2** — the `extensions` capability is stripped by the pre-2026 version sieve, so the UI extension can't be advertised to legacy-era clients. @@ -20,14 +26,14 @@ Every shim in the migration is temporary and carries a documented removal trigge | Shim | Location | Removal trigger | | --- | --- | --- | -| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | Removed with FastMCP's SEP-1686 wire machinery (`server/tasks/`), which is slated for removal now that the 2025 task protocol left the spec. The SEP-2663 rebuild does not need it — `CreateTaskResult` is claimed on `tools/call` through the extensions mechanism, which the SDK registries already admit. | +| `_sdk_patches.py` — task registry widening | `fastmcp_slim/fastmcp/_sdk_patches.py` | SDK adds `tasks/*` rows and `CreateTaskResult` to the `tools/call` result union (sdk-feedback #1). | | `_compat.py` — camelCase field bridge | `fastmcp_slim/fastmcp/_compat.py` | User-migration aid; removed in a future release after users migrate reads to snake_case. Users can preview removal with `mcp_camelcase_compat = False`. | | `FastMCPRequestContext` ContextVar | `fastmcp_slim/fastmcp/server/dependencies.py` | The SDK deliberately passes context as an argument with no ContextVar; FastMCP's public `get_context()` needs ambient access, and the shim also lifts `_meta`, which the SDK's `TypedDict` drops. No planned removal — this is a permanent boundary, not a beta gap. | | `FastMCPServerMiddleware` | `fastmcp_slim/fastmcp/server/low_level.py` | Already the native SDK `ServerMiddleware` path; no cleaner hook exists. Permanent. | | Client `get_session_id` header sniff | `fastmcp_slim/fastmcp/client/transports/http.py` | SDK exposes session id (or an `on_session_created` callback) from `streamable_http_client`, at parity with `sse_client` (sdk-feedback #5). | | `_sdk_context_shim.py` — generic handler aliases | `fastmcp_slim/fastmcp/client/_sdk_context_shim.py` | The SDK's `ClientRequestContext` is not subscriptable, so FastMCP keeps the public generic `SamplingHandler`/`RootsHandler`/`ElicitationHandler` aliases. Permanent unless the SDK makes the context subscriptable (sdk-feedback #7). | -The `TaskNotificationHandler` binding (sdk-feedback #8) is the client-side equivalent: it registers a `NotificationBinding` for the SEP-1686 `notifications/tasks/status` because the SDK no longer tees custom server notifications to the message handler. It goes away with the SEP-1686 wire machinery it serves; the `fastmcp-tasks` client half registers its own binding for the SEP-2663 `notifications/tasks` shape when it ships (push notifications are deferred to a later `fastmcp-tasks` version — v1 is polling-only). +The `TaskNotificationHandler` binding (sdk-feedback #8) is the client-side equivalent: it registers a `NotificationBinding` for `notifications/tasks/status` because the SDK no longer tees custom server notifications to the message handler. ## Statelessness on 2026-07-28 @@ -47,16 +53,16 @@ These are not bugs. The protocol removed the mechanism they depend on, so they a These work on `2026-07-28` today because they never leaned on a protocol session: -- **`tasks/get` polling.** Task result retrieval is keyed by `task_id` and backed by Docket/Redis, so a client polls across independent requests without any session affinity. This session-free polling is exactly why the execution engine survives the SEP-1686-to-SEP-2663 rework: the SEP-2663 wire shape (poll `tasks/get`, resolve in-task input via `tasks/update`) maps onto the same durable store, and SEP-2663's `Mcp-Name: <taskId>` routing header is moot for a shared-Redis deployment where any replica can serve the poll. See [the xfail register](#the-xfail-register). +- **`tasks/get` polling.** Task result retrieval is keyed by `task_id` and backed by Docket/Redis, so a client polls across independent requests without any session affinity. - **OAuth bearer validation.** Auth is per-request bearer validation — every POST carries and re-validates its own credential. - **In-request progress and logging notifications.** Notifications emitted while a request is still streaming ride that POST's SSE sink and are delivered normally. ### Design holes deferred to the multi-protocol workstream -The remaining items are real holes, deferred to the [first-class 2026 client](feature-program.md#first-class-2026-client) workstream because they all reduce to one unanswered question — *what is a session when the protocol has none?* The danger in each is that the code currently returns without erroring, which reads as "works" but is actually silent degradation. Again: these affect `2026-07-28` connections only; on the handshake eras every one of them behaves correctly. +The remaining items are real holes, deferred to the [first-class 2026 client](/development/v4-notes/feature-program#first-class-2026-client) workstream because they all reduce to one unanswered question — *what is a session when the protocol has none?* The danger in each is that the code currently returns without erroring, which reads as "works" but is actually silent degradation. Again: these affect `2026-07-28` connections only; on the handshake eras every one of them behaves correctly. - **`ctx.session_id` and `ctx.set_state` / `ctx.get_state` (broken even single-replica).** On a modern request `ctx.session_id` mints a fresh `uuid4`, cached on the per-request `connection.state` that is discarded when the request returns. So `ctx.set_state` and `ctx.get_state` silently never round-trip across requests — no error, just lost data. The open design decision is whether `session_id` should become `None` with `set_state` documented as session-era-only, or be re-based on an app-level key (the auth subject, or a client-supplied header). -- **Task push and in-task input — resolved by the SEP-2663 design, not a statelessness hole.** This was previously framed as a hole because SEP-1686 leaned on a push back-channel (the notification/elicitation relay) that dies once the submitting request returns. SEP-2663 removes the dependency: in-task input is *poll-based* — the task enters `input_required`, surfaces its outstanding elicit/sample/roots requests in an `inputRequests` map on `tasks/get`, and the client answers via `tasks/update`. That round-trips through the durable store with no session affinity, so it is stateless-safe by construction. The SEP-1686 push relay (`server/tasks/elicitation.py`, `notifications.py`) is removed; the `fastmcp-tasks` rebuild implements the poll-based channel instead. Foreground (non-task) elicitation on 2026 remains the guard-mode `InputRequiredResult`. +- **Task push and background elicitation (broken even single-replica).** The initial task-status notification is delivered only while the submitting POST is still streaming; the standalone subscription task pushes into a dead sink and its cleanup fires at request end, and the Redis relay is keyed by the throwaway per-request session id. Elicitation from a background task is impossible on 2026 by protocol construction — it needs an explicit era-gate that raises a clear error rather than hanging. Task-status push on 2026 would require adopting `subscriptions/listen` (which does not carry task events) or declaring the era poll-only. - **Stateful proxy affinity (degraded).** The stateful proxy's `_caches` are keyed by the per-request `Connection`, so on modern connections the proxy collapses to stateless proxying: results stay correct, but the per-session affinity guarantee is lost. This is decided alongside the `session_id` question — same root — or gated to the legacy/stdio transports. Multi-replica concerns (per-process rate-limiter buckets, shared Redis backends for state and tasks, a Redis `SubscriptionBus`) are deployment configuration rather than protocol gaps and are out of scope for this section. @@ -65,16 +71,16 @@ Multi-replica concerns (per-process rate-limiter buckets, shared Redis backends FastMCP acts as an advisor to the SDK team. The migration produced a dossier of ten findings (`sdk-feedback.md`) — verified bugs and hard edges to report upstream, plus questions to bundle into a feedback thread. The highest-priority items: -- **#1 (bug)** — SEP-1686 task result types ship but the method registries omit them. *Moot: the SEP-1686 wire shape was removed from the spec; the SEP-2663 rebuild claims `CreateTaskResult` on `tools/call` through the extensions mechanism, which the registries already admit.* -- **#2 (bug/question)** — `capabilities.extensions` stripped at pre-2026 negotiated versions. **Elevated:** this now gates the `io.modelcontextprotocol/tasks` extension (and MCP Apps) on the modern era, so it blocks a flagship v4 feature rather than an edge case. Worth prioritizing in the upstream thread. +- **#1 (bug)** — SEP-1686 task result types ship but the method registries omit them. +- **#2 (bug/question)** — `capabilities.extensions` stripped at pre-2026 negotiated versions. - **#4 (security)** — DCR redirect-URI validation accepts `javascript:`/`data:` schemes. - **#5 (hard edge)** — `streamable_http_client` drops session-id access with no replacement. - **#8 (hard edge)** — custom server notifications are dropped, not tee'd to `message_handler`. -- **#10 (hard edge)** — 2026 push-feature degradation error quality is inconsistent. *Resolved on the FastMCP side: `ctx.elicit` / `ctx.sample` are era-gated to raise a clear error on modern connections (#4448).* +- **#10 (hard edge)** — 2026 push-feature degradation error quality is inconsistent. Filing is gated on maintainer approval of each issue text. -Separately, the [SDK delegation round two](feature-program.md#sdk-delegation-round-two) work depends on **three upstream feature requests** — per-session event-store scoping, a user-middleware injection hook, and a lifespan hook — that would let FastMCP collapse its HTTP builders onto the SDK's and inherit the SDK's session-owner credential enforcement. +Separately, the [SDK delegation round two](/development/v4-notes/feature-program#sdk-delegation-round-two) work depends on **three upstream feature requests** — per-session event-store scoping, a user-middleware injection hook, and a lifespan hook — that would let FastMCP collapse its HTTP builders onto the SDK's and inherit the SDK's session-owner credential enforcement. ## GA transition checklist diff --git a/docs/docs.json b/docs/docs.json index c52daada0..4eb3cd51b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -16,7 +16,7 @@ "dark": "#475569", "light": "#1e3a5f" }, - "content": "FastMCP 4 is in beta — build stateful applications on sessionless MCP. [See what's new](/getting-started/whats-new)." + "content": "Meet [Prefect Horizon](https://prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_banner&utm_content=sitewide_banner), the enterprise MCP gateway built by the team behind FastMCP" }, "colors": { "dark": "#f72585", @@ -67,7 +67,7 @@ "label": "" }, { - "href": "https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=header", + "href": "https://prefect.io/horizon", "icon": "cloud", "label": "Prefect Horizon" } @@ -89,8 +89,7 @@ "pages": [ "getting-started/welcome", "getting-started/installation", - "getting-started/quickstart", - "getting-started/whats-new" + "getting-started/quickstart" ] }, { @@ -145,7 +144,6 @@ "pages": [ "servers/elicitation", "servers/sampling", - "servers/completions", "servers/progress", "servers/logging", "servers/pagination", @@ -161,8 +159,6 @@ "servers/dependency-injection", "servers/lifespan", "servers/storage-backends", - "servers/sessions", - "servers/extensions", "servers/tasks", "servers/versioning" ] @@ -268,7 +264,6 @@ "icon": "key", "pages": [ "clients/auth/oauth", - "clients/auth/client-credentials", "clients/auth/cimd", "clients/auth/bearer" ], @@ -359,12 +354,10 @@ "group": "Upgrading", "icon": "up", "pages": [ - "getting-started/upgrading/from-fastmcp-3", "getting-started/upgrading/from-fastmcp-2", - "getting-started/upgrading/from-mcp-sdk-v1", - "getting-started/upgrading/from-mcp-sdk-v2", - "getting-started/upgrading/from-low-level-sdk-v1", - "getting-started/upgrading/from-low-level-sdk-v2" + "getting-started/upgrading/from-fastmcp-3", + "getting-started/upgrading/from-mcp-sdk", + "getting-started/upgrading/from-low-level-sdk" ] }, { @@ -375,7 +368,17 @@ "development/contributing", "development/tests", "development/releases", - "patterns/contrib" + "patterns/contrib", + { + "collapsed": true, + "group": "v4 Notes", + "pages": [ + "development/v4-notes/index", + "development/v4-notes/change-register", + "development/v4-notes/feature-program", + "development/v4-notes/known-gaps" + ] + } ] }, { @@ -404,10 +407,7 @@ "icon": "code" } ], - "version": "v4.0.0 (beta 1)" - }, - { - "$ref": "./v3-navigation.json" + "version": "v3" }, { "$ref": "./v2-navigation.json" @@ -415,30 +415,6 @@ ] }, "redirects": [ - { - "destination": "/getting-started/whats-new", - "source": "/development/v4-notes/index" - }, - { - "destination": "/getting-started/upgrading/from-fastmcp-3", - "source": "/development/v4-notes/change-register" - }, - { - "destination": "/getting-started/whats-new", - "source": "/development/v4-notes/feature-program" - }, - { - "destination": "/getting-started/whats-new", - "source": "/development/v4-notes/protocol-2026" - }, - { - "destination": "/getting-started/upgrading/from-fastmcp-3", - "source": "/development/v4-notes/known-gaps" - }, - { - "destination": "/servers/tasks", - "source": "/development/v4-notes/background-tasks" - }, { "destination": "/apps/fastmcp-app", "source": "/apps/interactive-apps" @@ -512,21 +488,13 @@ "source": "/development/upgrade-guide" }, { - "destination": "/getting-started/upgrading/from-mcp-sdk-v1", + "destination": "/getting-started/upgrading/from-mcp-sdk", "source": "/getting-started/upgrading-from-sdk" }, { - "destination": "/getting-started/upgrading/from-mcp-sdk-v1", - "source": "/getting-started/upgrading/from-mcp-sdk" - }, - { - "destination": "/getting-started/upgrading/from-low-level-sdk-v1", + "destination": "/getting-started/upgrading/from-low-level-sdk", "source": "/getting-started/low-level-sdk" }, - { - "destination": "/getting-started/upgrading/from-low-level-sdk-v1", - "source": "/getting-started/upgrading/from-low-level-sdk" - }, { "destination": "/getting-started/upgrading/from-fastmcp-3", "source": "/getting-started/upgrading/to-mcp-sdk-v2" diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 8c3167fb6..4dae8e9b7 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -7,19 +7,15 @@ icon: arrow-down-to-line We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install and manage FastMCP. -```bash -uv add fastmcp -``` - -Or with pip: - ```bash pip install fastmcp ``` -<Note> -**FastMCP 4 is in prerelease.** The commands above install the latest stable release, which is still 3.x. To get v4, pin the beta explicitly with `pip install "fastmcp==4.0.0b1"`, or see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease) for the uv constraint you'll need. -</Note> +Or with uv: + +```bash +uv add fastmcp +``` ### Optional Dependencies @@ -44,8 +40,8 @@ You should see output like the following: ```bash $ fastmcp version -FastMCP version: 4.0.0b1 -MCP version: 2.0.0 +FastMCP version: 3.0.0 +MCP version: 1.25.0 Python version: 3.12.2 Platform: macOS-15.3.1-arm64-arm-64bit FastMCP root path: ~/Developer/fastmcp @@ -66,27 +62,19 @@ Alternatively, wait for the stable v5 release. See [this issue](https://github.c </Info> ## Upgrading -### From FastMCP 3.0 - -Most FastMCP 3 servers run on 4 without changes. See [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) for the breaks that do exist, and [What's New](/getting-started/whats-new) for what the new version adds. - ### From FastMCP 2.0 See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complete list of breaking changes and migration steps. ### From the MCP SDK -Which guide you want depends on which `mcp` version you're on and which of its two server APIs you used. +#### From FastMCP 1.0 -#### From the high-level server +If you're using FastMCP 1.0 via the `mcp` package (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers, it's a single import change. See the [full upgrade guide](/getting-started/upgrading/from-mcp-sdk) for details. -If you're using FastMCP 1.0 via SDK v1 (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers it's a single import change. See [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1), which also explains why that route is usually easier than moving to MCP SDK v2. +#### From the Low-Level Server API -If you already moved to SDK v2 and write against `MCPServer`, see [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2) — that migration is mostly renaming. - -#### From the low-level server - -If you built your server directly on the `mcp` package's `Server` class, the guide you want depends on how its handlers are registered. Decorators like `@server.list_tools()` mean SDK v1 — see [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1). Handlers passed to the constructor as `on_list_tools=` mean SDK v2 — see [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2). +If you built your server directly on the `mcp` package's `Server` class — with `list_tools()`/`call_tool()` handlers and hand-written JSON Schema — see the [migration guide](/getting-started/upgrading/from-low-level-sdk) for a full walkthrough. ## Troubleshooting @@ -115,12 +103,16 @@ FastMCP follows semantic versioning with pragmatic adaptations for the rapidly e For production use, always pin to exact versions: ``` -fastmcp==4.0.0b1 # Good - an exact version -fastmcp>=4.0.0 # Bad - may install breaking changes +fastmcp==3.0.0 # Good +fastmcp>=3.0.0 # Bad - may install breaking changes ``` See the full [versioning and release policy](/development/releases#versioning-policy) for details on our public API, deprecation practices, and breaking change philosophy. ## Contributing to FastMCP -The [Contributing Guide](/development/contributing) covers setting up a development environment, running the test suite and pre-commit hooks, and the standards we hold contributed code to. +Interested in contributing to FastMCP? See the [Contributing Guide](/development/contributing) for details on: +- Setting up your development environment +- Running tests and pre-commit hooks +- Submitting issues and pull requests +- Code standards and review process diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 79c4599a3..97d9f3c79 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -3,7 +3,7 @@ title: Quickstart icon: rocket-launch --- -This guide builds a working MCP server from scratch: a tool, a way to run it, a client that calls it, and a visual UI for the result. It ends with the server deployed and reachable over the internet. +Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, give it a visual UI, and deploy it to Prefect Horizon. If you haven't already installed FastMCP, follow the [installation instructions](/getting-started/installation). @@ -112,7 +112,10 @@ async def call_tool(name: str): asyncio.run(call_tool("Ford")) ``` -FastMCP clients are asynchronous, so the call goes through `asyncio.run`. Entering the client context with `async with client:` is what opens the connection, and it stays open for as many calls as you want to make inside the block. +Note that: +- FastMCP clients are asynchronous, so we need to use `asyncio.run` to run the client +- We must enter a client context (`async with client:`) before using the client +- You can make multiple client calls within the same context ## Give Your Tool a UI @@ -142,11 +145,9 @@ def greet(name: str) -> PrefabApp: You can preview app tools locally with `fastmcp dev apps my_server.py` — no MCP host required. See the [Apps overview](/apps/overview) for the full guide, including state management, forms, charts, and server-connected interactivity. -## Deploy Your Server +## Deploy to Prefect Horizon -FastMCP HTTP servers run anywhere you can host a Python application. The [HTTP deployment guide](/deployment/http) covers the transport settings and security boundaries for self-managed infrastructure. - -For a managed deployment, [Prefect Horizon](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides hosting, authentication, access control, and observability for MCP servers. +[Prefect Horizon](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides managed hosting, authentication, access control, and observability for MCP servers. <Info> Horizon is **free for personal projects** and offers enterprise governance for teams. diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx index c371f09f6..f066b6d0b 100644 --- a/docs/getting-started/upgrading/from-fastmcp-2.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx @@ -1,15 +1,11 @@ --- title: Upgrading from FastMCP 2 sidebarTitle: "From FastMCP 2" -description: What changed in FastMCP 3 for servers written against FastMCP 2 +description: Migration instructions for upgrading between FastMCP versions icon: up --- -This guide covers the breaking changes a FastMCP 2 server meets on its way to FastMCP 3, newest release first. - -<Note> -**Going all the way to FastMCP 4?** You need this page and [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), in that order. The two describe different transitions: this one covers the v3 API changes, while the FastMCP 3 guide covers the MCP Python SDK v2 rebuild underneath v4. Where a v3 deprecation was later removed outright, this page marks it **Removed in v4**. -</Note> +This guide covers breaking changes and migration steps when upgrading FastMCP. ## v3.0.0 @@ -25,7 +21,7 @@ pip install --upgrade fastmcp uv add --upgrade fastmcp ``` -If you pin versions in a requirements file or `pyproject.toml`, update your pin to `fastmcp>=3.0.0,<4`. Going on to FastMCP 4 is a second hop: finish this page, then work through [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) and move the pin to `fastmcp>=4.0.0` at the end of it. +If you pin versions in a requirements file or `pyproject.toml`, update your pin to `fastmcp>=3.0.0,<4`. <Info> **New repository home.** As part of the v3 release, FastMCP's GitHub repository has moved from `jlowin/fastmcp` to [`PrefectHQ/fastmcp`](https://github.com/PrefectHQ/fastmcp) under [Prefect](https://prefect.io)'s stewardship. GitHub automatically redirects existing clones and bookmarks, so nothing breaks — but you can update your local remote whenever convenient: @@ -85,7 +81,7 @@ BREAKING CHANGES (will crash at import or runtime): 12. REPO MOVE: GitHub repository moved from jlowin/fastmcp to PrefectHQ/fastmcp. Update git remotes and dependency URLs that reference the old location. -13. BACKGROUND TASKS: FastMCP's background task system is now an optional dependency. If the code uses task=True or TaskConfig, add pip install "fastmcp[tasks]". +13. BACKGROUND TASKS: FastMCP's background task system (SEP-1686) is now an optional dependency. If the code uses task=True or TaskConfig, add pip install "fastmcp[tasks]". DEPRECATIONS (still work but emit warnings): @@ -105,7 +101,7 @@ For each issue found, show the original line, explain why it breaks, and provide In v2, you could configure transport settings directly in the `FastMCP()` constructor. In v3, `FastMCP()` is purely about your server's identity and behavior — transport configuration happens when you actually start serving. Passing any of the old kwargs now raises `TypeError` with a migration hint. -```python test="skip" +```python # Before mcp = FastMCP("server", host="0.0.0.0", port=8080) mcp.run() @@ -144,7 +140,7 @@ Keeping `DiskStore` requires `pip install 'py-key-value-aio[disk]'`, which re-in In v2, you could enable or disable individual components by calling methods on the component object itself. In v3, visibility is controlled through the server (or provider), which lets you target components by name, tag, or type without needing a reference to the object: -```python test="skip" +```python # Before tool = await server.get_tool("my_tool") tool.disable() @@ -159,7 +155,7 @@ Calling `.enable()` or `.disable()` on a component object now raises `NotImpleme The `get_tools()`, `get_resources()`, `get_prompts()`, and `get_resource_templates()` methods have been renamed to `list_tools()`, `list_resources()`, `list_prompts()`, and `list_resource_templates()`. More importantly, they now return lists instead of dicts — so code that indexes by name needs to change: -```python test="skip" +```python # Before tools = await server.get_tools() tool = tools["my_tool"] @@ -173,9 +169,9 @@ tool = next((t for t in tools if t.name == "my_tool"), None) Prompt functions now use FastMCP's `Message` class instead of `mcp.types.PromptMessage`. The new class is simpler — it accepts a plain string and defaults to `role="user"`, so most prompts become one-liners: -```python test="skip" +```python # Before -from mcp.types import PromptMessage, TextContent +from fastmcp.types import PromptMessage, TextContent @mcp.prompt def my_prompt() -> PromptMessage: @@ -191,7 +187,7 @@ def my_prompt() -> Message: If your prompt functions return raw dicts with `role` and `content` keys, those also need to change. v2 silently coerced dicts into prompt messages, but v3 requires typed `Message` objects (or plain strings for single user messages): -```python test="skip" +```python # Before (v2 accepted this) @mcp.prompt def my_prompt(): @@ -215,7 +211,7 @@ def my_prompt() -> list[Message]: `ctx.set_state()` and `ctx.get_state()` are now async because state in v3 is session-scoped and backed by a pluggable storage backend (rather than a simple dict). This means state persists across multiple tool calls within the same session: -```python test="skip" +```python # Before ctx.set_state("key", "value") value = ctx.get_state("key") @@ -227,7 +223,7 @@ value = await ctx.get_state("key") State values must also be JSON-serializable by default (dicts, lists, strings, numbers, etc.). If you need to store non-serializable values like an HTTP client, pass `serializable=False` — these values are request-scoped and only available during the current tool call: -```python test="skip" +```python await ctx.set_state("client", my_http_client, serializable=False) ``` @@ -249,7 +245,7 @@ parent.mount(child, namespace="child") In v2, auth providers like `GitHubProvider` could auto-load configuration from environment variables with a `FASTMCP_SERVER_AUTH_*` prefix. This magic has been removed — pass values explicitly: -```python test="skip" +```python # Before (v2) — client_id and client_secret loaded automatically # from FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID, etc. auth = GitHubProvider() @@ -282,7 +278,7 @@ transport = StreamableHttpTransport("http://localhost:8000/mcp") `OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx2 client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout: -```python test="skip" +```python # Before provider = OpenAPIProvider(spec, client, timeout=60) @@ -295,7 +291,7 @@ provider = OpenAPIProvider(spec, client) The FastMCP metadata key in component `meta` dicts changed from `_fastmcp` to `fastmcp`. If you read metadata from tool or resource objects, update the key: -```python test="skip" +```python # Before tags = tool.meta.get("_fastmcp", {}).get("tags", []) @@ -313,7 +309,7 @@ Metadata is now always included — the `include_fastmcp_meta` parameter has bee In v2, `@mcp.tool` transformed your function into a `FunctionTool` object. In v3, decorators return your original function unchanged — which means decorated functions stay callable for testing, reuse, and composition: -```python test="skip" +```python @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" @@ -325,7 +321,7 @@ If you have code that treats the decorated result as a `FunctionTool` (e.g., acc **Background tasks require optional dependency** -FastMCP's background task system is now behind an optional extra. If your server uses background tasks, install with: +FastMCP's background task system (SEP-1686) is now behind an optional extra. If your server uses background tasks, install with: ```bash pip install "fastmcp[tasks]" @@ -339,7 +335,7 @@ These were deprecated in v3. Items marked **Removed in v4** no longer work at al **mount() prefix → namespace** (Removed in v4) -```python test="skip" +```python # Removed in v4 main.mount(subserver, prefix="api") @@ -349,7 +345,7 @@ main.mount(subserver, namespace="api") **import_server() → mount()** (Removed in v4) -```python test="skip" +```python # Removed in v4 main.import_server(subserver) @@ -386,7 +382,7 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)]) **add_tool_transformation() → add_transform()** (Removed in v4) -```python test="skip" +```python # Removed in v4 mcp.add_tool_transformation("name", config) @@ -399,7 +395,7 @@ mcp.add_transform(ToolTransform({"name": config})) The proxy target is passed positionally in both APIs, so most calls migrate unchanged. If you passed the target by keyword, note that the parameter was renamed from `backend=` to `target=`. -```python test="skip" +```python # Removed in v4 proxy = FastMCP.as_proxy("http://example.com/mcp") proxy = FastMCP.as_proxy(backend="http://example.com/mcp") # keyword form @@ -428,18 +424,12 @@ server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)]) ### Removed Deprecated Features -A batch of long-deprecated surfaces came out in 2.14. Each fails loudly at import or call time, and each has a direct replacement: - -| Removed | Replacement | -|---|---| -| `BearerAuthProvider` | `JWTVerifier` — the same JWT validation under a name that says what it does | -| `Context.get_http_request()` | `get_http_request()` from [dependency injection](/servers/dependency-injection) | -| `from fastmcp import Image` | `from fastmcp.utilities.types import Image` | -| `FastMCP(dependencies=[...])` | a [`fastmcp.json`](/deployment/server-configuration) configuration file | -| `FastMCPProxy(client=...)` | `client_factory=lambda: ...` | -| `output_schema=False` | `output_schema=None` | - -Two of these are worth understanding rather than just swapping. `FastMCPProxy` takes a factory instead of a client because a single shared client cannot serve concurrent proxied sessions safely — the factory gives each session its own backend connection. And `output_schema=False` became `output_schema=None` because `False` read as "this tool has a schema, and it is false"; `None` says plainly that there is no schema. +- `BearerAuthProvider` → use `JWTVerifier` +- `Context.get_http_request()` → use `get_http_request()` from dependencies +- `from fastmcp import Image` → use `from fastmcp.utilities.types import Image` +- `FastMCP(dependencies=[...])` → use `fastmcp.json` configuration +- `FastMCPProxy(client=...)` → use `client_factory=lambda: ...` +- `output_schema=False` → use `output_schema=None` ## v2.13.0 @@ -447,7 +437,7 @@ Two of these are worth understanding rather than just swapping. `FastMCPProxy` t The OAuth proxy now issues its own JWT tokens. For production, provide explicit keys: -```python test="skip" +```python auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx index 1cd484680..eb132ca7c 100644 --- a/docs/getting-started/upgrading/from-fastmcp-3.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx @@ -1,124 +1,37 @@ --- title: Upgrading from FastMCP 3 -sidebarTitle: "From FastMCP 3" +sidebarTitle: "From FastMCP 3.x" description: What changes when you upgrade to FastMCP 4, which builds on the MCP Python SDK v2 icon: up --- -FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it moves the protocol types into a standalone `mcp_types` package (still importable as `mcp.types`), and it renames every model field from camelCase to snake_case in Python (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on). The wire format does not change: the models keep their camelCase aliases and serialize under them, so this renames the attributes your code reads, not the JSON on the connection. +FastMCP 4 builds on the MCP Python SDK v2, and that is the source of every change in this guide. The SDK v2 makes two sweeping changes to the protocol layer: it splits the protocol types out of `mcp.types` into a standalone `mcp_types` package, and it renames every protocol field from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, `isError` → `is_error`, and so on). -FastMCP 4 absorbs almost all of this for you. Field access is bridged so your existing reads keep working, and the imports you were taught have a stable home in FastMCP itself. What the SDK cannot hide is the protocol's own direction: the new sessionless era removes the server's ability to call back into a client mid-request, and background tasks moved out of the core spec into an extension. Those two shape the changes a working server is most likely to feel. +FastMCP 4 absorbs almost all of this for you. Field access is bridged so your existing reads keep working, and the imports you were taught have a stable home in FastMCP itself. The sections below describe what FastMCP handles for you, the small number of changes you must make in your own code, and the deprecation timeline for the compatibility shims. -The sections below cover what FastMCP handles for you, the changes you must make in your own code, the surfaces removed outright in 4.0, the behavior shifts that compile fine but act differently, and the deprecation timeline for the compatibility shims. - -## Install the v4 Prerelease - -While FastMCP 4 is in prerelease, pin the beta explicitly. The `fastmcp` package is a thin wrapper that depends on `fastmcp-slim` at the same version, so asking for a prerelease of one means asking for a prerelease of the other. pip infers that on its own: - -```bash -pip install "fastmcp==4.0.0b1" -``` - -uv is stricter: it allows prereleases only for packages you name, and `fastmcp-slim` arrives transitively. Constrain it alongside the requirement in `pyproject.toml`: - -```toml -[project] -dependencies = ["fastmcp==4.0.0b1"] - -[tool.uv] -constraint-dependencies = ["fastmcp-slim==4.0.0b1"] -``` - -Then run `uv lock` or `uv sync` normally. Naming the one package keeps the rest of your graph on stable releases, where `--prerelease allow` would opt every dependency into prereleases. The MCP SDK needs no constraint at all now that it ships stable releases — pinning `mcp==2.0.0b2` here would in fact break the resolution, since a prerelease does not satisfy FastMCP's own `mcp>=2.0.0` requirement. - -<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance."> -You are upgrading an MCP server or client from FastMCP 3.x to FastMCP 4, which is built on the MCP Python SDK v2. - -FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3 — it explains every item below, with the replacement code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs. - -Then search the provided code for each signal below. Most FastMCP 3 servers upgrade untouched, so report only what you actually find. - -ENVIRONMENT -- a pydantic pin below 2.12 -- a FastAPI pin below 0.133.0, the first release admitting Starlette 1.x (earlier ones cap it, e.g. 0.115.12 requires `starlette<0.47.0`), or any direct Starlette pin below 1.0.1 - -IMPORTS THAT NO LONGER RESOLVE -- `fastmcp.server.proxy`, `fastmcp.server.openapi`, `FastMCPOpenAPI` -- `fastmcp.experimental.server.openapi`, `fastmcp.experimental.utilities.openapi` -- `fastmcp.experimental.sampling.handlers` -- `fastmcp.server.apps`, `fastmcp.server.app` -- `fastmcp.tools.tool`, `fastmcp.resources.resource`, `fastmcp.prompts.prompt` -- `fastmcp.server.tasks`, `fastmcp.server.sampling` -- `fastmcp.server.auth.authorization` -- `CurrentDocket` or `CurrentWorker` from `fastmcp.dependencies` -- `SkillsProvider` -- `CachableToolResult`, `CachablePromptResult`, and their siblings (the misspelling was corrected with no alias) -- `PromptToolMiddleware`, `ResourceToolMiddleware` - -REMOVED SERVER METHODS AND KEYWORDS -- `FastMCP.as_proxy(...)` -- `import_server(...)` ← flag this one loudly: `mount()` is the replacement but NOT an equivalent. `import_server` took a static snapshot and skipped the child's lifespan and middleware; `mount` is a live composition that runs both. -- `mount(prefix=...)`, `mount(as_proxy=...)` -- `add_tool_transformation(...)`, `remove_tool_transformation(...)` -- `remove_tool(...)` ← its replacement raises KeyError where this raised NotFoundError, so check surrounding except clauses -- tool `serializer=`, tool `exclude_args=` -- `StreamableHttpTransport(sse_read_timeout=...)` -- `FASTMCP_DECORATOR_MODE` / `settings.decorator_mode` -- `FastMCP(sampling_handler=...)`, `sampling_handler_behavior=` - -REMOVED CONTEXT METHODS -- `ctx.sample(...)`, `ctx.sample_step(...)`, `ctx.list_roots(...)` -- Note for the user: if borrowing the CALLER's model is the whole point of the server, the guide's recommendation is to stay on FastMCP 3.x rather than migrate. -- The client side is NOT affected — `Client(sampling_handler=...)` and `Client(roots=...)` still mean what they meant. - -RUNTIME BREAKS THAT STILL COMPILE — the ones most likely to reach production -- `ctx.elicit(...)` anywhere. It is era-gated in 4.0 and raises on modern connections, which is what `Client` now negotiates by default. This is the single most likely runtime failure. -- `ctx.elicit(...)` called without `response_type` -- `except httpx.` around any FastMCP call. FastMCP raises httpx2 exceptions now, but httpx is usually still installed transitively, so the handler imports, type-checks, and silently never matches. -- a custom `httpx.AsyncClient`, `httpx_client_factory=`, or `httpx.Auth` handed to a FastMCP transport, `OAuth`, or `from_openapi` -- `Middleware.on_initialize` hooks, and `ctx.set_state` values read back in a later call — neither survives a modern connection -- middleware assuming `on_message` only sees routable requests -- camelCase field reads (`inputSchema`, `isError`, `mimeType`, `nextCursor`, `structuredContent`, `serverInfo`, and the rest) — these still work but warn, and are scheduled for removal -- clients matching on the resource-not-found error code -32002 -- templated resources whose parameters legitimately carry `..` or absolute paths -- an OAuth server (`OAuthProxy` or anything built on it) with `issuer_url` set to something other than `base_url` — this forces a one-time re-authorization of every client - -BACKGROUND TASKS -- `@mcp.tool(task=True)` or `TaskConfig` without `mcp.add_extension(TasksExtension())` -- `task=` on a `@mcp.resource` or `@mcp.prompt` decorator (tools only now) -- `client.call_tool(..., task=True)`, `read_resource(task=True)`, `get_prompt(task=True)` - -ERRORS -- `McpError(ErrorData(...))` positional construction. Catching and `err.error.code` are unchanged; only construction moved. - -For each item found, show the original line, name what changed, and give the corrected code from the guide. Where you could not confirm a replacement in the docs, say so instead of guessing. -</Prompt> - -## Environment Requirements +## Environment requirements The SDK v2 raises FastMCP's dependency floors, which matters before any of your code runs. **pydantic >= 2.12 is now the floor.** If your project pins an older pydantic (for example `pydantic==2.11.*`), installing this FastMCP release fails with an unsatisfiable-resolution error from your installer — bump your pin to `>=2.12` first. If you don't pin pydantic at all, installers upgrade it silently as part of the FastMCP upgrade. -**The server extra floors Starlette >= 1.0.1.** This is the requirement most likely to force an unrelated upgrade, because FastAPI pinned Starlette to a sub-1.0 range for a long time — FastAPI 0.115.12, for example, requires `starlette<0.47.0`. **FastAPI 0.133.0 is the first release that admits Starlette 1.x**, so a project pinned below that gets an unsatisfiable resolution rather than a version bump. Raise your FastAPI pin to `>=0.133.0` before upgrading FastMCP. Mounting a FastMCP server inside a FastAPI app is otherwise unaffected — verified against FastAPI 0.135.2 on Starlette 1.3.1. +**The server extra floors Starlette >= 1.0.** Modern FastAPI (0.11x and later) already runs on Starlette 1.x, so mounting a FastMCP server inside a FastAPI app coexists cleanly — verified with FastAPI 0.138.2. Only very old FastAPI versions pinned below Starlette 1.0 conflict; upgrade FastAPI if your resolver complains about Starlette. -## What FastMCP Absorbs +## What FastMCP absorbs -### camelCase Field Access +### Legacy camelCase field access keeps working Objects that FastMCP hands back to you — the results of `client.list_tools()`, `client.call_tool_mcp()`, `client.read_resource()`, and the parameter objects passed to your sampling and elicitation handlers — are SDK v2 objects with snake_case fields. FastMCP installs a compatibility bridge at import time that routes the old camelCase names to their new snake_case fields, so code written against FastMCP 2.x still reads correctly: ```python from fastmcp import Client - -async def read_schema(): - async with Client("my_mcp_server.py") as client: - tools = await client.list_tools() - return tools[0].inputSchema # still works, warns once +async with Client("my_mcp_server.py") as client: + tools = await client.list_tools() + schema = tools[0].inputSchema # still works, warns once ``` -Each bridged read emits a `FastMCPDeprecationWarning` pointing you at the snake_case name (`tools[0].input_schema` here). The bridge covers the fields users actually read: `inputSchema`/`outputSchema` on tools; `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` on tool annotations; `mimeType` on resources and content; `isError`/`structuredContent` on tool results; `nextCursor` on paginated results; `serverInfo`/`protocolVersion` on the initialize result; the sampling parameter fields (`systemPrompt`, `maxTokens`, `stopSequences`, `modelPreferences`, `toolChoice`); and `requestedSchema` on elicitation parameters. +Each bridged read emits a `FastMCPDeprecationWarning` pointing you at the snake_case name (`tools[0].input_schema` here). The bridge covers the fields users actually read: `inputSchema`/`outputSchema` on tools, `mimeType` on resources and content, `isError`/`structuredContent` on tool results, `nextCursor` on paginated results, `serverInfo`/`protocolVersion` on the initialize result, the sampling parameter fields (`systemPrompt`, `maxTokens`, `stopSequences`, `modelPreferences`, `toolChoice`), and `requestedSchema` on elicitation parameters. The bridge is controlled by the `mcp_camelcase_compat` setting, which defaults to on. Set it to `False` (or the environment variable `FASTMCP_MCP_CAMELCASE_COMPAT=false`) to turn the shims off, in which case only the snake_case names resolve: @@ -130,19 +43,23 @@ fastmcp.settings.mcp_camelcase_compat = False See [Settings](/more/settings) for the full reference. -### Protocol Types +### Imports have a stable home -Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, notification and request wrapper types like `ToolListChangedNotification`, and everything else — now lives in a standalone `mcp_types` package. The SDK re-exports that package as `mcp.types`, so existing imports keep working and stay the preferred spelling: +The `mcp.types` module no longer exists. FastMCP re-exports the protocol types you're most likely to use — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Icon`, `PromptMessage`, `SamplingMessage`, `ToolAnnotations`, and around two dozen others — from `fastmcp.types`. Update your imports to point there: ```python -from mcp.types import TextContent, Tool, ToolAnnotations +from fastmcp.types import TextContent, Tool, ToolAnnotations ``` -Both names resolve to the same objects, so `from mcp_types import X` is equally valid — useful if you depend on the types without the rest of the SDK. What did change is the fields on those types: they are snake_case now (`input_schema`, not `inputSchema`), which the [compatibility bridge](#legacy-camelcase-field-access-keeps-working) covers for the objects FastMCP hands you. +For protocol types FastMCP does not re-export (notification and request wrapper types like `ToolListChangedNotification` or `ServerNotification`), import them from `mcp_types` directly: -`fastmcp.types` still exists, but holds only types FastMCP defines itself (currently just `Textarea`, used to render a multiline textarea in form-based UIs) — it does not re-export protocol types. +```python +import mcp_types -### The `McpError` Alias +notification = mcp_types.ToolListChangedNotification() +``` + +### `McpError` has an alias `fastmcp.exceptions.McpError` is an alias of the SDK's `MCPError`. Catching errors is unchanged — `except McpError` still catches SDK-raised errors, and reading `err.error.code` still works: @@ -155,7 +72,7 @@ except McpError as err: print(err.error.code) ``` -### Preserved Behavior +### Behavior preserved across the SDK boundary A few client behaviors that touch the SDK are preserved so you don't have to change anything: @@ -163,9 +80,17 @@ A few client behaviors that touch the SDK are preserved so you don't have to cha - `client.ping()` returns a `bool`. - `client.transport.get_session_id()` returns `None` on protocol eras that have no session, rather than raising. (The SDK v2 removed session-id access from its streamable HTTP transport; FastMCP reconstructs it on the transport object.) -## What You Must Change +## What you must change -Everything above, FastMCP handled for you. What remains lives in your own code, where FastMCP can't reach it — how you construct errors, the custom HTTP clients you hand to a transport, and any place you reach past FastMCP's surfaces into the raw SDK objects. Each surfaces as a clear failure at import or call time, and each is a mechanical fix. +Three things are on you. + +**Your own `mcp.types` imports.** FastMCP can re-export types, but it can't rewrite imports in your code. Any `from mcp.types import X` or `import mcp.types` in your server or client fails at import time with: + +``` +ModuleNotFoundError: No module named 'mcp.types' +``` + +The raw message gives no hint toward the fix, so if you see it after upgrading, this is why. Switch to `from fastmcp.types import X` for the common types, or `import mcp_types` for the rest. **`McpError` construction.** The v1 pattern of wrapping an `ErrorData` and passing it positionally fails under SDK v2 with: @@ -175,7 +100,7 @@ TypeError: MCPError.__init__() missing 1 required positional argument: 'message' Note the message prints the class as `MCPError` (uppercase) even though your code wrote `McpError` — the old name is an alias for the SDK's renamed class. Construct the error with keyword arguments instead: -```python test="skip" +```python from fastmcp.exceptions import McpError # Before (raises TypeError under SDK v2): @@ -191,7 +116,7 @@ Catching and `err.error.code` are unchanged — only construction moved. **FastMCP now uses httpx2 exclusively.** FastMCP has replaced `httpx` with [httpx2](https://pypi.org/project/httpx2/), a next-generation httpx fork, across its entire HTTP stack — client transports and every server-side path (auth providers, the OpenAPI integration, the version check). `httpx` is no longer a FastMCP dependency. If you pass a custom client or factory into a FastMCP client transport — `StreamableHttpTransport(httpx_client_factory=...)`, `SSETransport(httpx_client_factory=...)`, `OAuth(httpx_client_factory=...)`, or a custom `httpx.Auth` as `Client(auth=...)` — those objects must now be httpx2. httpx2 is a drop-in fork with the same public API, so the change is an import swap: -```python test="skip" +```python # Before import httpx @@ -209,247 +134,52 @@ transport = StreamableHttpTransport( ) ``` -The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) should now be an `httpx2.AsyncClient`. Existing `httpx.AsyncClient` instances remain temporarily accepted via duck typing, but emit a `FastMCPDeprecationWarning` and will be rejected in a future release. HTTP made inside your own tools is entirely yours and is unaffected. +The `client` you pass to `FastMCP.from_openapi(client=...)` (and `OpenAPIProvider(client=...)`) is now type-hinted `httpx2.AsyncClient`. FastMCP does not gate on the type, so an existing `httpx.AsyncClient` keeps working at runtime via duck-typing this release — but switching it to `httpx2.AsyncClient` clears the type hint and is the supported path going forward. HTTP made inside your own tools is entirely yours and is unaffected either way. **The subtlest break is exception handlers, and no type checker will catch it.** `httpx` very likely remains installed in your environment (the Anthropic, OpenAI, and Google SDKs all depend on it), so code that catches old-httpx exceptions around FastMCP calls still imports and still type-checks — it just never matches, because FastMCP now raises `httpx2` exceptions. The handler silently becomes dead code: ```python import httpx # still installed transitively — this import works - -async def fetch(client, url): - try: - return await client.call_tool("fetch", {"url": url}) - except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError - return fallback() +try: + result = await client.call_tool("fetch", {"url": url}) +except httpx.ConnectError: # dead code: FastMCP now raises httpx2.ConnectError + return fallback() ``` Grep your codebase for `except httpx.` and move those handlers to `httpx2`. The exception hierarchies match name-for-name, so the fix is an import swap — the hard part is remembering to look. One place you are covered automatically: exceptions raised *inside your tools and resources* (for example, a tool whose own old-httpx call gets a 429) are still mapped to `ToolError`/`ResourceError` by FastMCP's error boundary, which recognizes both libraries' exceptions during the transition. Two runtime behaviors shift with httpx2, and because the switch is now wholesale they apply to **all** FastMCP HTTP — including server-auth upstream calls, not just the client path. TLS verification uses the operating system's trust store (via `truststore`, honoring `SSL_CERT_FILE`/`SSL_CERT_DIR`) instead of the bundled certifi CA set, so corporate-CA or certifi-pinned setups may verify differently. And the FastMCP HTTP loggers are renamed from `httpx`/`httpcore.*` to `httpx2`/`httpcore2.*` — update any logging filters that select the HTTP stack by logger name. -## Removed in FastMCP 4 - -Deprecations that warned throughout the 3.x line are removed in 4.0. Unlike the bridged changes above, these fail immediately at the call site — a `ModuleNotFoundError`, `ImportError`, `AttributeError`, or `TypeError` — so nothing degrades silently. Every one has a direct replacement, and the fix is mechanical. - -### Moved Imports - -The proxy, OpenAPI, and app integrations moved to their permanent homes, and the internal component classes are no longer re-exported from their old aliases: - -| Removed import | Replacement | -| --- | --- | -| `fastmcp.server.proxy` | `fastmcp.server.providers.proxy` | -| `fastmcp.server.openapi` (and `FastMCPOpenAPI`) | `FastMCP` with an `OpenAPIProvider` from `fastmcp.server.providers.openapi` | -| `fastmcp.experimental.server.openapi` | `fastmcp.server.providers.openapi` | -| `fastmcp.experimental.utilities.openapi` | `fastmcp.utilities.openapi` | -| `fastmcp.server.apps`, `fastmcp.server.app` | `fastmcp.apps` (e.g. `AppConfig`) or `fastmcp` (`FastMCPApp`) | -| `Tool` / `ToolResult` from `fastmcp.tools.tool` | `fastmcp.tools` | -| `Resource` from `fastmcp.resources.resource` | `fastmcp.resources` | -| `Prompt` / `Message` from `fastmcp.prompts.prompt` | `fastmcp.prompts` | -| `FunctionTool` / `ParsedFunction` / `tool` from `fastmcp.tools.tool` | `fastmcp.tools.function_tool` | -| `FunctionResource` / `resource` from `fastmcp.resources.resource` | `fastmcp.resources.function_resource` | -| `FunctionPrompt` / `prompt` from `fastmcp.prompts.prompt` | `fastmcp.prompts.function_prompt` | -| `OpenAISamplingHandler` from `fastmcp.experimental.sampling.handlers` | `fastmcp.client.sampling.handlers.openai` | -| `AuthCheck` / `AuthContext` / `require_scopes` / `require_roles` / `restrict_tag` / `run_auth_checks` from `fastmcp.server.auth.authorization` | `fastmcp.server.auth` | -| `run_auth_checks_with_shortfall` / `scope_requirements` from `fastmcp.server.auth.authorization` | `fastmcp.utilities.authorization` | -| `SkillsProvider` | `SkillsDirectoryProvider` from `fastmcp.server.providers.skills` | -| `TaskConfig` from `fastmcp.server.tasks` | `fastmcp.utilities.tasks` | -| `CurrentDocket` / `CurrentWorker` from `fastmcp.dependencies` | `fastmcp_tasks.dependencies` | -| `fastmcp.server.sampling` (and `SamplingTool`) | removed with [server-side sampling](#protocol-version-support) | - -Two renames in the same family are worth calling out because they have no compatibility alias. The response-caching wrapper models lost a spelling typo — `CachableToolResult`, `CachablePromptResult`, and their siblings became `CacheableToolResult`, `CacheablePromptResult`, etc. — so an import of the old spelling from `fastmcp.server.middleware.caching` raises `ImportError`. And `PromptToolMiddleware` / `ResourceToolMiddleware` are gone in favor of the `PromptsAsTools` / `ResourcesAsTools` transforms from `fastmcp.server.transforms` (the `ToolInjectionMiddleware` base class is retained). - -### Removed Server Methods - -These `FastMCP` methods and keywords have warned since 3.0 and are now removed: - -| Removed | Replacement | -| --- | --- | -| `FastMCP.as_proxy(sub)` | `create_proxy(sub)` (from `fastmcp.server`) | -| `mcp.import_server(sub)` | `mcp.mount(sub)` | -| `mcp.mount(sub, prefix="x")` | `mcp.mount(sub, namespace="x")` | -| `mcp.mount(sub, as_proxy=True)` | wrap with `create_proxy(sub)`, then `mount` the proxy | -| `mcp.add_tool_transformation(name, cfg)` | `mcp.add_transform(ToolTransform({name: cfg}))` | -| `mcp.remove_tool_transformation(name)` | removed (was a no-op); hide tools with `mcp.disable(keys=[...])` | -| `mcp.remove_tool(name)` | `mcp.local_provider.remove_tool(name)` | - -Two of these replacements are not exact behavioral swaps. `create_proxy` takes its target as the first positional argument (`target`), so a keyword call like `as_proxy(backend=server)` becomes `create_proxy(server)` rather than reusing the old keyword. And `local_provider.remove_tool` raises a plain `KeyError` when the tool is missing, where `FastMCP.remove_tool` raised a `NotFoundError` — update any `except NotFoundError` cleanup around a removal. - -`mount(as_proxy=True)` used to route the child through a proxy (an MCP-client execution boundary) rather than composing it directly. To keep that boundary, wrap the child in `create_proxy()` and mount the proxy; a plain `mount(child)` composes the child in-process. Either way, the child's lifespan and middleware now run — a direct mount no longer skips them. - -`import_server` → `mount` is the one row here that is not a mechanical swap, because the two never had the same semantics. `import_server` took a **one-time static snapshot** — it copied the child's tools, resources, and prompts at call time, with no live link, and did not run the child's lifespan or middleware. `mount` is a **live composition** — it holds a live link to the child and runs the child's lifespan and middleware. After switching, later changes to the child become visible through the parent, the child's lifespan runs with the parent's (entered when the server starts, held until it stops — not per request), and the child's middleware runs on the operations delegated to it. If you depended on the frozen-copy behavior (a stable snapshot, no child lifecycle), there is no drop-in replacement: register the child's components on the parent directly instead of composing the two servers. - -### Removed Parameters - -Several parameters and settings that warned in 3.x are gone: - -- **Tool `serializer=`** is removed from `@tool` / `mcp.tool()`, `Tool.from_function`, `Tool.from_tool`, and the OpenAPI tool. Return a `ToolResult` from your tool for full control over serialization instead. -- **Tool `exclude_args=`** is removed. Hide a parameter from the tool schema by injecting it instead: give it a `Depends(factory)` default (from `fastmcp.dependencies`), where `factory` is a callable returning the value the argument used to carry. An injected parameter never appears in the tool's schema, which is what `exclude_args` was for. -- **The `decorator_mode` setting** (`FASTMCP_DECORATOR_MODE`) and its `"object"` mode are removed. Decorators always return your original function with metadata attached; reach the component object through the server (`await mcp.get_tool("name")`) rather than off the decorated function. -- **`StreamableHttpTransport(sse_read_timeout=...)`** is removed — it was a no-op under the SDK v2 client. Set the read timeout through the public `Client(transport, timeout=...)` (a `timedelta` or float seconds), or reach for a custom `httpx_client_factory` when you need finer control. (`SSETransport` still accepts `sse_read_timeout`.) -- **`ctx.elicit()` now requires `response_type`.** Omitting it (or passing `None`) has warned since 3.2 and now raises `TypeError`. The empty-object schema it produced gave clients nothing to render, and some showed an empty, non-functional form. Pass a type describing what you expect back — `bool` is the right answer for a confirmation: - - ```python test="skip" - # Before - result = await ctx.elicit("Approve this action?") - - # After - result = await ctx.elicit("Approve this action?", response_type=bool) - ``` - - This is the server-authoring API only. Client elicitation handlers still receive `response_type=None` for URL requests and for empty schemas sent by other servers — that contract is unchanged. - -### Background Tasks - -Background tasks left the core MCP spec during the SDK v2 rebuild and came back as the `io.modelcontextprotocol/tasks` extension (SEP-2663). FastMCP follows the protocol: what was a built-in server feature in 3.x is now a registered extension, and the authoring surface changed on both sides of the connection. - -The extension ships in a separate package, so the pin from [Install the v4 Prerelease](#install-the-v4-prerelease) needs one more entry before any of this imports: - -```toml -[project] -dependencies = ["fastmcp[tasks]==4.0.0b1"] - -[tool.uv] -constraint-dependencies = [ - "fastmcp-slim==4.0.0b1", - "fastmcp-tasks==4.0.0b1", - "mcp==2.0.0b2", - "mcp-types==2.0.0b2", -] -``` - -On the server, `task=True` still marks a tool as capable of running in the background, but it no longer runs anything by itself — the extension does. Register it, or the server refuses to start: - -```python -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension - -mcp = FastMCP("MyServer") -mcp.add_extension(TasksExtension()) - -@mcp.tool(task=True) -async def slow_computation(duration: int) -> str: - """A long-running operation.""" - return "done" -``` - -Without the registration, a `task=True` tool raises at startup rather than the first time a client calls the tool: - -``` -RuntimeError: Task-enabled tools (slow_computation) require the tasks extension, -but no extension with identifier 'io.modelcontextprotocol/tasks' is registered. -``` - -`TaskConfig` moved from `fastmcp.server.tasks` to `fastmcp.utilities.tasks`, and the `CurrentDocket` and `CurrentWorker` dependencies moved to `fastmcp_tasks.dependencies`. - -`task=` is now a tool-only keyword. FastMCP 3 accepted it on resource, resource-template, and prompt decorators as well; passing it to `@mcp.resource` or `@mcp.prompt` now raises `TypeError`, and there is no replacement — the extension tasks tool calls only. - -The client API changed shape entirely. In 3.x you opted a single call into background execution with `task=True` and got a handle back. In 4.0 `call_tool` handles a tasked call transparently: if the server runs the call in the background, the client polls it to completion and returns the same result a synchronous call would have produced. - -```python -import fastmcp_tasks # noqa: F401 — importing anywhere enables client task support -from fastmcp import Client - - -async def run(server): - async with Client(server) as client: - return await client.call_tool("slow_computation", {"duration": 10}) -``` - -When you want the handle — to do other work while the task runs, check on it, or cancel it — `call_tool_task` returns one immediately: - -```python -from fastmcp import Client -from fastmcp_tasks import call_tool_task - - -async def run(server): - async with Client(server) as client: - task = await call_tool_task(client, "slow_computation", {"duration": 10}) - return await task.result() -``` - -Three things follow from this. `client.call_tool(name, args, task=True)` raises `TypeError`, as do `read_resource(task=True)` and `get_prompt(task=True)` — and those last two have no replacement. Client task support requires `fastmcp_tasks` to be imported somewhere in the process, since that import is what makes a `Client` advertise the capability. And tasks are negotiated only on modern connections, so a `mode="legacy"` client never gets them. See [Background Tasks](/servers/tasks) for the full picture. - -## Behavior Changes - -These changes compile fine and can surface at runtime. The first is the one most likely to bite a working 3.x server. - -**`ctx.elicit()` no longer reaches a default client.** Elicitation is era-gated in 4.0: `ctx.elicit()` works on handshake-era connections (≤ 2025-11-25) and raises on the modern `2026-07-28` protocol, which has no back-channel for a running tool to push a request down. Because `fastmcp.Client` now defaults to `mode="auto"`, an ordinary client negotiates the modern era against a FastMCP server — so a tool that elicited happily in 3.x now fails the call: - -``` -ToolError: elicitation via server-initiated requests is unavailable on 2026-07-28 connections. -``` - -The gate is strict in both directions, which is what makes it debuggable: a guard tool that returns an input request on a handshake connection raises the mirror-image error rather than misbehaving quietly. You have three ways forward. Rewrite the tool as a guard tool that *returns* a description of the input it needs, which is the form that works on modern connections. Branch on `ctx.request_context.protocol_version` and keep both paths if you serve both eras. Or keep this server's clients on the handshake era with `Client(server, mode="legacy")`, which leaves `ctx.elicit()` working as written. See [Elicitation](/servers/elicitation#which-approach-to-use) for the two shapes side by side. - -**Middleware sees traffic it never saw before.** Dispatch now begins in the SDK's middleware layer, the single point every inbound message passes through, so `on_message`, `on_request`, and `on_notification` observe *every* message a client sends — including `notifications/cancelled`, `notifications/initialized`, and `notifications/progress`, and including requests that fail before reaching a handler, such as an unknown method or a `tools/call` whose params fail validation. In 3.x those never reached your hooks. Middleware that assumed every message it saw was a routable request, or that counted messages to measure tool traffic, needs a guard on the message type. The operation hooks (`on_call_tool`, `on_list_tools`, and the rest) are unaffected: they still fire exactly once per request and still receive typed component results. See [What middleware sees](/servers/middleware#what-middleware-sees). - -**Templated resources are path-screened by default.** Every templated resource now has its extracted parameter values checked for path-traversal (`..` segments), absolute paths, and null bytes *before your handler runs*, at the server's read chokepoint. A rejected read returns a non-leaky "resource not found" error. Only a standalone `..` segment counts as traversal, so values that merely contain dots (`file.tar.gz`, `HEAD~3..HEAD`) and dotfiles (`.env`) still pass. If a template legitimately accepts `..`-bearing or absolute values, exempt the parameter with `ResourceSecurity(exempt_params={...})`, disable the check per-component with `security=None`, or set a server-wide default with `FastMCP(resource_security=...)`. See [Resources → Path Security](/servers/resources#path-security). - -**Resource-not-found now returns `-32602`.** The wire error code for a missing resource from the core `resources/read` handler changed from `-32002` to `-32602` (`INVALID_PARAMS`, per SEP-2164). The human-readable message ("Resource not found: ...") is unchanged, so this only affects clients that matched on the numeric code — update those to expect `-32602`. (The opt-in `ErrorHandlingMiddleware` keeps its own per-method-prefix code mapping; if you run it with `transform_errors=True` it can still map not-found to a different code, so it is unaffected by this change.) - -**An OAuth server whose `issuer_url` differs from its `base_url` re-authorizes its clients once.** `issuer_url` exists so a server's OAuth identity can differ from the URL its endpoints are mounted at — the usual case being a server under `/api` whose discovery lives at the host root. It now supplies the `issuer` in the authorization server metadata, the `iss` claim on every token the server mints, and the RFC 9207 `iss` on authorization responses; `base_url` still supplies `authorization_endpoint`, `token_endpoint`, and the rest, because that is where the routes are actually mounted. Both values previously came from `base_url`, which published an `issuer` contradicting the URL the client had just performed discovery at — a document RFC 8414 §3.3 requires a strict client to reject. - -The cost of the correction is the `iss` on tokens already in the wild, so it falls on the providers that mint their own tokens — `OAuthProxy` and everything built on it. Access *and* refresh tokens carry the claim, and the verifier compares it exactly, so clients cannot refresh their way across the upgrade; it is a one-time full re-authorization. Interactive clients re-prompt and recover on their own, while a headless deployment holding a long-lived refresh token needs someone to re-authorize it. Plan the upgrade for a window where that is acceptable. If an identity provider mints SEP-990 ID-JAG assertions for this server, repoint their `aud` at the new issuer too — unless you pin the expected value with `IdentityAssertion(audience=...)`, which overrides the issuer and keeps working untouched. - -Servers that leave `issuer_url` unset, or set it to the same value as `base_url`, are unaffected. It defaults to `base_url`, and the metadata and minted `iss` are byte-identical to what 3.x produced. - -## Deprecation Timeline +## Deprecation timeline The camelCase bridge is a migration aid, not a permanent fixture. It works today and warns on every bridged read so you can find and update the affected call sites. Plan to migrate your reads to snake_case: the shims will be removed in a future release, after which only the snake_case names resolve — the same state you get today by setting `mcp_camelcase_compat = False`. Turning the setting off is a good way to surface every remaining camelCase read in your code as a hard `AttributeError` before the shims go away. -## SDK Deprecation Warnings +## SDK deprecation warnings you may see -Ordinary use of `ctx.info` (client logging) emits an SDK-level `MCPDeprecationWarning`: +Ordinary use of `ctx.info` (client logging) and `ctx.sample` now emits an SDK-level `MCPDeprecationWarning`: ``` -The logging capability is deprecated as of 2026-07-28 (SEP-2577) +The logging/sampling capability is deprecated as of 2026-07-28 (SEP-2577) ``` -The warning comes from the MCP SDK, not from FastMCP, and it is benign. `ctx.info` and the rest of the logging methods keep working on every era, including the modern one — a log message is a *notification*, which rides the response stream the caller already opened. The SDK is signaling the protocol's direction for the capability declaration, not the notification itself. +These warnings come from the MCP SDK, not from FastMCP. For logging they are benign: `ctx.info` keeps working on session-based connections exactly as the protocol table below describes, and the SDK is only signaling the protocol's direction. For sampling, FastMCP additionally emits its own `FastMCPDeprecationWarning`: `ctx.sample` and `ctx.sample_step` are deprecated and slated for removal, so treat that warning as a prompt to migrate to server-side LLM calls rather than as informational. -## Protocol Version Support +## Protocol version support FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition. -**`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context`**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Touching a removed method raises `AttributeError` on every era, and `FastMCP(sampling_handler=...)` raises a `TypeError` naming the migration, so the break surfaces when you upgrade rather than in production against whichever client happens to negotiate the modern era. +Not every Context feature is available on every era yet. The push-style interactions that require the server to call back into the client — elicitation, sampling, and listing roots — depend on the session-based request/response flow of the earlier eras. On a `2026-07-28` connection these raise a clear, era-aware error rather than reaching the client. Logging notifications and the request/response features flow on every era. -All three *pushed*: the server sent a request down a live back-channel and blocked for the answer, and the sessionless protocol has no such channel. Since `fastmcp.Client` now negotiates the modern protocol by default, a method like that would fail against a default client. What the protocol removed is the pushing, not the asking — sampling, elicitation, and roots all still reach the client through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* an `InputRequiredResult` describing what it needs, the client answers, and it calls again with the answer attached. - -Migrating differs by capability. For **roots**, the guard pattern is the direct replacement: a server asks once and has what it needs, so the extra round buys the whole answer, and taking the paths as tool arguments is simpler still when the caller can just supply them. For **sampling**, the guard route works the same way, but generation usually belongs in your server, because every round is a full request-response cycle and a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model. If borrowing the caller's model *is* your server — you hold no key of your own, and the token bill was never yours to pay — staying on FastMCP 3.x is the honest answer until that changes. +Sampling is the exception that does not come back. `ctx.sample` and `ctx.sample_step` are **deprecated** and will be removed in a future FastMCP release: server-initiated sampling was removed from the wire by SEP-2577, and unlike elicitation it has no multi-round-trip replacement (the agentic loop would exhaust the round-trip budget). The migration is to call an LLM directly from your server rather than borrowing the client's model. See [Sampling](/servers/sampling) for details. | Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) | | --- | --- | --- | | `ctx.info` / logging notifications | Supported | Supported | | Tools, resources, prompts, completions | Supported | Supported | -| `ctx.elicit` | Supported | Raises — use the guard pattern (return `InputRequiredResult`) | -| `ctx.sample` / `ctx.sample_step` | Method removed — call an LLM server-side | Method removed — call an LLM server-side, or ask via the guard pattern | -| `ctx.list_roots` | Method removed — take paths as tool arguments | Method removed — ask via the guard pattern, or take paths as tool arguments | -| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` needs session state the era lacks | -| `Middleware.on_initialize` | Runs on connect | Never runs — there is no `initialize` handshake | -| Session state (`ctx.set_state` across calls) | Persists for the session | Does not persist — every request is a fresh connection | -| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension | +| `ctx.elicit` | Supported | Not yet — MRTR rewrite pending | +| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side | +| `ctx.list_roots` | Supported | Not yet — MRTR rewrite pending | +| Tasks (via the FastMCP client) | Supported | Not yet | -Several of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next; and a tool that calls [`ctx.elicit()`](#behavior-changes) raises. A server that gates access in `on_initialize`, relies on per-session state, or elicits mid-tool must keep its clients on the session-based era. The control is per-client: `Client(server, mode="legacy")`. There is no server-side setting that restricts which protocol versions a server offers, so a server whose behavior depends on the handshake era depends on its callers opting into it — which is only practical when you control them. If you don't, port the behavior instead: a guard tool for elicitation, [session state](/servers/sessions) for what `ctx.set_state` held, and per-request auth checks for what `on_initialize` gated. - -The client side is unaffected. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes, since a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler. - -## Upgrade Checklist - -Most servers upgrade untouched. Work down this list to find the ones that don't: - -1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`. -2. **Fix imports that moved out.** `from mcp.types import X` still works, but update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims). -3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods). -4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments. A server whose purpose is to use the caller's model should stay on FastMCP 3.x rather than migrate. -5. **Find every `ctx.elicit()` call.** It raises on modern connections, which is what a default client now negotiates. Rewrite the tool as a guard tool, branch on `ctx.request_context.protocol_version`, or keep its clients on `mode="legacy"` — see [the era gate](#behavior-changes). -6. **Register the tasks extension.** A `task=True` tool needs `mcp.add_extension(TasksExtension())` or the server won't start. Drop `task=` from resource and prompt decorators, move `TaskConfig` to `fastmcp.utilities.tasks`, and replace client-side `call_tool(..., task=True)` with plain `call_tool` or `call_tool_task`. -7. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`. -8. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged. -9. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`. -10. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize`, per-session state, or `ctx.elicit()`, keep its clients on `mode="legacy"`, or port the behavior forward — there is no server-side protocol-version restriction. -11. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, guard any middleware that now sees notifications and unroutable requests, update any client that matched the old `-32002` resource-not-found code, and if your server mints its own OAuth tokens (`OAuthProxy` and the providers built on it) under an `issuer_url` that differs from its `base_url`, schedule the [one-time re-authorization](#behavior-changes) its clients now need. -12. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed. - -The executable version of this checklist lives in [`tests/test_upgrade_from_v3.py`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/test_upgrade_from_v3.py): it builds representative 3.x-style servers and asserts they run unchanged, and pins every removed surface to the exact error it now raises. +If your tools rely on `ctx.elicit` or `ctx.list_roots`, they continue to work against clients on the earlier eras, and the sessionless replacements will expand this table as they land. Sampling is deprecated on every era and will not return on modern connections — migrate those tools to server-side LLM calls. diff --git a/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx b/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx deleted file mode 100644 index 35f5412bb..000000000 --- a/docs/getting-started/upgrading/from-low-level-sdk-v1.mdx +++ /dev/null @@ -1,623 +0,0 @@ ---- -title: Upgrading from the Low-Level SDK v1 -sidebarTitle: "From Low-Level SDK v1" -description: Upgrade your MCP server from v1 of the low-level Python SDK's Server class to FastMCP -icon: up ---- - -If you've been building MCP servers directly on the `mcp` package's `Server` class — writing `list_tools()` and `call_tool()` handlers, hand-crafting JSON Schema dicts, and wiring up transport boilerplate — this guide is for you. FastMCP replaces all of that machinery with a declarative, Pythonic API where your functions *are* the protocol surface. - -The core idea: instead of telling the SDK what your tools look like and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The plumbing you wrote to satisfy the protocol just disappears. - -## The SDK v2 Transition - -MCP SDK v2 is a substantial, deliberate modernization of the protocol layer. Protocol types moved into a standalone `mcp_types` package, wire fields moved from camelCase to snake_case, and the low-level `Server` was rebuilt so handlers are passed to the constructor as `on_*` callables taking `(ctx, params)` rather than registered with decorators. A v1 server meets that change the moment its environment resolves `mcp` to v2: - -``` -AttributeError: 'Server' object has no attribute 'list_tools' -``` - -Often nobody chose that moment. An unpinned `mcp` dependency, a fresh lockfile, or a rebuilt container picks up the new major version. Nothing is wrong with your code, and nothing is wrong with the SDK — major versions are exactly where a change like this belongs. Your build just crossed it earlier than you planned to. - -Pinning the SDK back restores the decorator API immediately, with no code changes, and buys you time to choose deliberately: - -```bash -pip install "mcp<2" -``` - -## Two Upgrade Paths - -Both directions are reasonable, and the choice is about which code you'd rather maintain. - -**Porting the low-level `Server` to SDK v2** keeps you in direct control of the protocol surface, which is the point of the low-level API and the right call for some servers. The work is real: your imports, every handler signature, every handler's return type, and your error construction all move. - -**Adopting FastMCP** is what the rest of this page walks through. What makes it less work is not that FastMCP is better — it's that the code most affected by the SDK v2 changes is precisely the code FastMCP doesn't ask you to write. Your `list_tools`/`call_tool` pair, hand-written JSON Schema, and content-block wrappers aren't ported to new signatures; they're deleted, and FastMCP derives all of it from your function signatures instead. FastMCP 4 runs on MCP SDK v2 underneath, so both paths land you on the same modern protocol layer. - -<Note> -Already on SDK v2's rebuilt `Server` class, with constructor-registered `on_*` handlers? See [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2) instead — the before-and-after code is different enough to warrant its own guide. - -Using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is a single import — see [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1). -</Note> - -<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance."> -You are rewriting an MCP server built on v1 of the `mcp` package's low-level `Server` class (`mcp.server.Server` or `mcp.server.lowlevel.server.Server`, with decorator-registered handlers) using FastMCP 4's high-level API. - -FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1 — it explains every item below, with before-and-after code for each handler group. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs. - -Then work through the provided code. This is a rewrite, not a patch: most of what you find gets deleted rather than translated. - -CONSTRUCTION AND TRANSPORT -- `Server("name")` -- `async with stdio_server() as (r, w): await server.run(r, w, server.create_initialization_options())` -- `SseServerTransport` / `StreamableHTTPSessionManager` and any Starlette wiring around them -- `asyncio.run(main())` boilerplate -- `lifespan=` — carries over directly: pass the same async context manager to `FastMCP(lifespan=...)`, and read what it yields from `ctx.lifespan_context` in any tool. Do not drop it — the tools that depended on it (a DB connection, a client pool) lose their dependency silently if you do. - -HANDLERS TO DELETE (each becomes one or more decorated functions) -- `@server.list_tools()` + `@server.call_tool()` — note the `if name == ...` dispatch chain inside call_tool; each branch becomes its own `@mcp.tool` -- `@server.list_resources()` + `@server.list_resource_templates()` + `@server.read_resource()` — note any manual URI parsing, which the `{placeholder}` syntax replaces -- `@server.list_prompts()` + `@server.get_prompt()` -- any other `@server.*()` handler in the file — completion, resource subscribe/unsubscribe, logging level, progress. Look these up in the FastMCP docs rather than assuming a decorator name maps one-to-one. - -TYPES THAT DISAPPEAR FROM YOUR CODE -- hand-written `inputSchema` JSON Schema dicts — these come from type hints now -- `types.Tool`, `types.Resource`, `types.ResourceTemplate`, `types.Prompt`, `types.PromptArgument` -- `types.TextContent` wrappers around return values — return plain Python values instead -- `types.ImageContent`, `types.EmbeddedResource` -- `types.PromptMessage`, `types.GetPromptResult` -- Note that in the SDK v2 that FastMCP 4 builds on, `mcp.types` aliases the standalone `mcp_types` package; the import path still works, but the fields are snake_case now. - -CONTEXT AND SIDE CHANNELS -- `server.request_context` -- `session.send_log_message(...)`, `session.send_progress_notification(...)` -- direct session use for anything else — a FastMCP `Context` has a `ctx.session` property returning the underlying SDK session, so this still works; prefer a `Context` method where one exists, and note the remaining uses as SDK-coupled - -ERRORS -- `raise ValueError(f"Unknown tool: ...")` and other dispatch fallbacks — these become unnecessary -- `McpError` construction and any error-code mapping - -For each item found, show the original code, say what it did, and give the FastMCP equivalent. Where several handlers collapse into one decorated function, show the collapse rather than a line-by-line mapping. Call out anything you could not find a documented FastMCP replacement for instead of inventing one. -</Prompt> - -## Install - -FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3: - -```bash -pip install "fastmcp==4.0.0b1" -# or -uv add "fastmcp==4.0.0b1" -``` - -An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). - -FastMCP depends on the `mcp` package, so the SDK stays installed. FastMCP 4 builds on SDK v2, where the protocol types live in a standalone `mcp_types` package that stays importable as `mcp.types`. Most of your `mcp.types` imports disappear entirely in the rewrite below, since FastMCP derives the protocol types from your function signatures. - -## Server and Transport - -The `Server` class requires you to choose a transport, connect streams, build initialization options, and run an event loop. FastMCP collapses all of that into a constructor and a `run()` call. - -<CodeGroup> - -```python Before test="skip" -import asyncio -from mcp.server import Server -from mcp.server.stdio import stdio_server - -server = Server("my-server") - -# ... register handlers ... - -async def main(): - async with stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - -asyncio.run(main()) -``` - -```python After -from fastmcp import FastMCP - -mcp = FastMCP("my-server") - -# ... register tools, resources, prompts ... - -if __name__ == "__main__": - mcp.run() -``` - -</CodeGroup> - -Need HTTP instead of stdio? With the `Server` class, you'd wire up Starlette routes and `SseServerTransport` or `StreamableHTTPSessionManager`. With FastMCP: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("my-server") - -if __name__ == "__main__": - mcp.run(transport="http", host="0.0.0.0", port=8000) -``` - -## Tools - -This is where the difference is most dramatic. The `Server` class requires two handlers — one to describe your tools (with hand-written JSON Schema) and another to dispatch calls by name. FastMCP eliminates both by deriving everything from your function signature. - -<CodeGroup> - -```python Before test="skip" -import mcp.types as types -from mcp.server import Server - -server = Server("math") - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="add", - description="Add two numbers", - inputSchema={ - "type": "object", - "properties": { - "a": {"type": "number"}, - "b": {"type": "number"}, - }, - "required": ["a", "b"], - }, - ), - types.Tool( - name="multiply", - description="Multiply two numbers", - inputSchema={ - "type": "object", - "properties": { - "a": {"type": "number"}, - "b": {"type": "number"}, - }, - "required": ["a", "b"], - }, - ), - ] - -@server.call_tool() -async def call_tool( - name: str, arguments: dict -) -> list[types.TextContent]: - if name == "add": - result = arguments["a"] + arguments["b"] - return [types.TextContent(type="text", text=str(result))] - elif name == "multiply": - result = arguments["a"] * arguments["b"] - return [types.TextContent(type="text", text=str(result))] - raise ValueError(f"Unknown tool: {name}") -``` - -```python After -from fastmcp import FastMCP - -mcp = FastMCP("math") - -@mcp.tool -def add(a: float, b: float) -> float: - """Add two numbers""" - return a + b - -@mcp.tool -def multiply(a: float, b: float) -> float: - """Multiply two numbers""" - return a * b -``` - -</CodeGroup> - -Each `@mcp.tool` function is self-contained: its name becomes the tool name, its docstring becomes the description, its type annotations become the JSON Schema, and its return value is serialized automatically. No routing. No schema dictionaries. No content-type wrappers. - -### Type Mapping - -When converting your `inputSchema` to Python type hints: - -| JSON Schema | Python Type | -|---|---| -| `{"type": "string"}` | `str` | -| `{"type": "number"}` | `float` | -| `{"type": "integer"}` | `int` | -| `{"type": "boolean"}` | `bool` | -| `{"type": "array", "items": {"type": "string"}}` | `list[str]` | -| `{"type": "object"}` | `dict` | -| Optional property (not in `required`) | `param: str \| None = None` | - -### Return Values - -With the `Server` class, tools return `list[types.TextContent | types.ImageContent | ...]`. In FastMCP, return plain Python values — strings, numbers, dicts, lists, dataclasses, Pydantic models — and serialization is handled for you. - -For images or other non-text content, FastMCP provides helpers: - -```python -from fastmcp import FastMCP -from fastmcp.utilities.types import Image - -mcp = FastMCP("media") - -@mcp.tool -def create_chart(data: list[float]) -> Image: - """Generate a chart from data.""" - png_bytes = generate_chart(data) # your logic - return Image(data=png_bytes, format="png") -``` - -## Resources - -The `Server` class uses three handlers for resources: `list_resources()` to enumerate them, `list_resource_templates()` for URI templates, and `read_resource()` to serve content — all with manual routing by URI. FastMCP replaces all three with per-resource decorators. - -<CodeGroup> - -```python Before test="skip" -import json -import mcp.types as types -from mcp.server import Server -from pydantic import AnyUrl - -server = Server("data") - -@server.list_resources() -async def list_resources() -> list[types.Resource]: - return [ - types.Resource( - uri=AnyUrl("config://app"), - name="app_config", - description="Application configuration", - mimeType="application/json", - ), - types.Resource( - uri=AnyUrl("config://features"), - name="feature_flags", - description="Active feature flags", - mimeType="application/json", - ), - ] - -@server.list_resource_templates() -async def list_resource_templates() -> list[types.ResourceTemplate]: - return [ - types.ResourceTemplate( - uriTemplate="users://{user_id}/profile", - name="user_profile", - description="User profile by ID", - ), - types.ResourceTemplate( - uriTemplate="projects://{project_id}/status", - name="project_status", - description="Project status by ID", - ), - ] - -@server.read_resource() -async def read_resource(uri: AnyUrl) -> str: - uri_str = str(uri) - if uri_str == "config://app": - return json.dumps({"debug": False, "version": "1.0"}) - if uri_str == "config://features": - return json.dumps({"dark_mode": True, "beta": False}) - if uri_str.startswith("users://"): - user_id = uri_str.split("/")[2] - return json.dumps({"id": user_id, "name": f"User {user_id}"}) - if uri_str.startswith("projects://"): - project_id = uri_str.split("/")[2] - return json.dumps({"id": project_id, "status": "active"}) - raise ValueError(f"Unknown resource: {uri}") -``` - -```python After -import json -from fastmcp import FastMCP - -mcp = FastMCP("data") - -@mcp.resource("config://app", mime_type="application/json") -def app_config() -> str: - """Application configuration""" - return json.dumps({"debug": False, "version": "1.0"}) - -@mcp.resource("config://features", mime_type="application/json") -def feature_flags() -> str: - """Active feature flags""" - return json.dumps({"dark_mode": True, "beta": False}) - -@mcp.resource("users://{user_id}/profile") -def user_profile(user_id: str) -> str: - """User profile by ID""" - return json.dumps({"id": user_id, "name": f"User {user_id}"}) - -@mcp.resource("projects://{project_id}/status") -def project_status(project_id: str) -> str: - """Project status by ID""" - return json.dumps({"id": project_id, "status": "active"}) -``` - -</CodeGroup> - -Static resources and URI templates use the same `@mcp.resource` decorator — FastMCP detects `{placeholders}` in the URI and automatically registers a template. The function parameter `user_id` maps directly to the `{user_id}` placeholder. - -## Prompts - -Same pattern: the `Server` class uses `list_prompts()` and `get_prompt()` with manual routing. FastMCP uses one decorator per prompt. - -<CodeGroup> - -```python Before test="skip" -import mcp.types as types -from mcp.server import Server - -server = Server("prompts") - -@server.list_prompts() -async def list_prompts() -> list[types.Prompt]: - return [ - types.Prompt( - name="review_code", - description="Review code for issues", - arguments=[ - types.PromptArgument( - name="code", - description="The code to review", - required=True, - ), - types.PromptArgument( - name="language", - description="Programming language", - required=False, - ), - ], - ) - ] - -@server.get_prompt() -async def get_prompt( - name: str, arguments: dict[str, str] | None -) -> types.GetPromptResult: - if name == "review_code": - code = (arguments or {}).get("code", "") - language = (arguments or {}).get("language", "") - lang_note = f" (written in {language})" if language else "" - return types.GetPromptResult( - description="Code review prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent( - type="text", - text=f"Please review this code{lang_note}:\n\n{code}", - ), - ) - ], - ) - raise ValueError(f"Unknown prompt: {name}") -``` - -```python After -from fastmcp import FastMCP - -mcp = FastMCP("prompts") - -@mcp.prompt -def review_code(code: str, language: str | None = None) -> str: - """Review code for issues""" - lang_note = f" (written in {language})" if language else "" - return f"Please review this code{lang_note}:\n\n{code}" -``` - -</CodeGroup> - -Returning a `str` from a prompt function automatically wraps it as a user message. For multi-turn prompts, return a `list[Message]`: - -```python -from fastmcp import FastMCP -from fastmcp.prompts import Message - -mcp = FastMCP("prompts") - -@mcp.prompt -def debug_session(error: str) -> list[Message]: - """Start a debugging conversation""" - return [ - Message(f"I'm seeing this error:\n\n{error}"), - Message("I'll help you debug that. Can you share the relevant code?", role="assistant"), - ] -``` - -## Request Context - -The `Server` class exposes request context through `server.request_context`, which gives you the raw `ServerSession` for sending notifications. FastMCP replaces this with a typed `Context` object injected into any function that declares it. - -<CodeGroup> - -```python Before test="skip" -import mcp.types as types -from mcp.server import Server - -server = Server("worker") - -@server.call_tool() -async def call_tool(name: str, arguments: dict): - if name == "process_data": - ctx = server.request_context - await ctx.session.send_log_message( - level="info", data="Starting processing..." - ) - # ... do work ... - await ctx.session.send_log_message( - level="info", data="Done!" - ) - return [types.TextContent(type="text", text="Processed")] -``` - -```python After -from fastmcp import FastMCP, Context - -mcp = FastMCP("worker") - -@mcp.tool -async def process_data(ctx: Context) -> str: - """Process data with progress logging""" - await ctx.info("Starting processing...") - # ... do work ... - await ctx.info("Done!") - return "Processed" -``` - -</CodeGroup> - -The `Context` object provides logging (`ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`), progress reporting (`ctx.report_progress()`), resource subscriptions, session state, and more. See [Context](/servers/context) for the full API. - -## Errors - -Most of the errors a low-level server raises disappear with the dispatch that raised them: the `ValueError(f"Unknown tool: {name}")` fallback is unnecessary once FastMCP routes calls, and an exception from your function body is converted to a tool error for you. - -Deliberate protocol errors are the exception, and they need a small rewrite. The v1 pattern wrapped an `ErrorData` and passed it positionally; FastMCP's `McpError` takes the fields directly: - -```python test="skip" -from fastmcp.exceptions import McpError - -# Before (SDK v1): -# raise McpError(ErrorData(code=-32000, message="Upstream unavailable")) - -# After: -raise McpError(code=-32000, message="Upstream unavailable") -``` - -An optional third argument, `data=`, carries the structured payload `ErrorData` used to hold. Catching is unchanged — `except McpError` still works, and `err.error.code` still reads the code — so only construction sites need touching. - -## Complete Example - -A full server upgrade, showing how all the pieces fit together: - -<CodeGroup> - -```python Before expandable test="skip" -import asyncio -import json -import mcp.types as types -from mcp.server import Server -from mcp.server.stdio import stdio_server -from pydantic import AnyUrl - -server = Server("demo") - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="greet", - description="Greet someone by name", - inputSchema={ - "type": "object", - "properties": { - "name": {"type": "string"}, - }, - "required": ["name"], - }, - ) - ] - -@server.call_tool() -async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: - if name == "greet": - return [types.TextContent(type="text", text=f"Hello, {arguments['name']}!")] - raise ValueError(f"Unknown tool: {name}") - -@server.list_resources() -async def list_resources() -> list[types.Resource]: - return [ - types.Resource( - uri=AnyUrl("info://version"), - name="version", - description="Server version", - ) - ] - -@server.read_resource() -async def read_resource(uri: AnyUrl) -> str: - if str(uri) == "info://version": - return json.dumps({"version": "1.0.0"}) - raise ValueError(f"Unknown resource: {uri}") - -@server.list_prompts() -async def list_prompts() -> list[types.Prompt]: - return [ - types.Prompt( - name="summarize", - description="Summarize text", - arguments=[ - types.PromptArgument(name="text", required=True) - ], - ) - ] - -@server.get_prompt() -async def get_prompt( - name: str, arguments: dict[str, str] | None -) -> types.GetPromptResult: - if name == "summarize": - return types.GetPromptResult( - description="Summarize text", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent( - type="text", - text=f"Summarize:\n\n{(arguments or {}).get('text', '')}", - ), - ) - ], - ) - raise ValueError(f"Unknown prompt: {name}") - -async def main(): - async with stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, write_stream, - server.create_initialization_options(), - ) - -asyncio.run(main()) -``` - -```python After -import json -from fastmcp import FastMCP - -mcp = FastMCP("demo") - -@mcp.tool -def greet(name: str) -> str: - """Greet someone by name""" - return f"Hello, {name}!" - -@mcp.resource("info://version") -def version() -> str: - """Server version""" - return json.dumps({"version": "1.0.0"}) - -@mcp.prompt -def summarize(text: str) -> str: - """Summarize text""" - return f"Summarize:\n\n{text}" - -if __name__ == "__main__": - mcp.run() -``` - -</CodeGroup> - -## What You Gain - -Deleting the handler machinery is the immediate payoff, but the reason to make this move is what becomes available once your server is a FastMCP server. - -[Server composition](/servers/composition) mounts one server inside another, so a surface that grew unwieldy as a single `call_tool` dispatch splits into modules developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching — the cross-cutting concerns that, on the low-level `Server`, meant threading the same code through every handler. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control, and the [OpenAPI integration](/integrations/openapi) generates an entire server from an API specification you already have. [Authentication](/servers/auth/authentication) arrives as a single `auth=` provider covering token verification, OAuth, and named providers for GitHub, Google, Auth0, and others. - -The change most likely to affect your daily work is [testing](/servers/testing). FastMCP ships a client that connects to a server object in the same Python process, so a test calls your tools directly — no subprocess, no stdio pipes, no transport to stand up. diff --git a/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx b/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx deleted file mode 100644 index f4222c0f0..000000000 --- a/docs/getting-started/upgrading/from-low-level-sdk-v2.mdx +++ /dev/null @@ -1,622 +0,0 @@ ---- -title: Upgrading from the Low-Level SDK v2 -sidebarTitle: "From Low-Level SDK v2" -description: Move a server built on v2 of the low-level Python SDK's Server class to FastMCP -icon: up ---- - -If your server builds on the `mcp` package's low-level `Server` class as SDK v2 rebuilt it — handlers passed to the constructor as `on_list_tools`, `on_call_tool`, and their siblings, each taking `(ctx, params)` and returning a wrapped result object — this guide is for you. FastMCP replaces that machinery with a declarative API where your functions *are* the protocol surface. - -The core idea: instead of describing your tools to the SDK and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The dispatch you wrote to route a call by name, and the schemas you wrote by hand to describe it, both disappear. - -Migrating from SDK v2 is the most direct of the four upgrade paths, because you and FastMCP already share a protocol layer. FastMCP 4 is built on SDK v2, so `mcp_types` imports keep working, field names are already snake_case, and the era negotiation you get is the one you have. Almost nothing about the wire changes — the one exception is [argument strictness](#stricter-arguments), covered below. - -<Note> -On SDK v1's decorator-registered `Server` — `@server.list_tools()`, `@server.call_tool()` — instead? See [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1), where the before-and-after code matches that API. - -Using SDK v2's high-level `MCPServer` class? See [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2) — that migration is mostly renaming. -</Note> - -<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance."> -You are rewriting an MCP server built on the MCP Python SDK v2's low-level `Server` class (`mcp.server.lowlevel.server.Server`, with `on_*` handlers passed to the constructor) using FastMCP 4's high-level API. - -FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2 — it explains every item below in full, with before-and-after code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not guess at a FastMCP API you have not confirmed in the docs. - -Then work through the provided code looking for each of these. The guide has the replacement for every one: - -CONSTRUCTION AND TRANSPORT -- `Server(name, on_list_tools=..., on_call_tool=..., ...)` — the whole constructor, including every handler passed to it -- `server.run(read_stream, write_stream, server.create_initialization_options())` and its `stdio_server()` context manager -- `server.streamable_http_app()` and any Starlette app assembled around it -- `asyncio.run(main())` boilerplate -- `lifespan=` — carries over directly: pass the same async context manager to `FastMCP(lifespan=...)`, and read what it yields from `ctx.lifespan_context` in any tool. Do not drop it — the tools that depended on it (a DB connection, a client pool) lose their dependency silently if you do. - -HANDLERS TO DELETE, EACH REPLACED BY ONE DECORATOR (not simply removed) -- `on_list_tools` + `on_call_tool` → one `@mcp.tool` function per branch of the `if params.name == ...` dispatch chain inside `on_call_tool` -- `on_list_resources` + `on_list_resource_templates` + `on_read_resource` → one `@mcp.resource` function per resource/template -- `on_list_prompts` + `on_get_prompt` → one `@mcp.prompt` function per prompt -- `on_completion` → one `@mcp.completion` function. This one is easy to drop by mistake: skipping it does not just remove autocomplete cleanly, it silently stops FastMCP from advertising the completions capability at all, since that capability is only advertised when a handler is registered. -- `on_subscribe_resource` / `on_unsubscribe_resource` / `on_subscriptions_listen` — flag for the user, no single-decorator equivalent -- `on_set_logging_level`, `on_progress`, `on_roots_list_changed`, `on_ping` — flag for the user, these are protocol-level hooks with no direct FastMCP surface - -TYPES THAT DISAPPEAR FROM YOUR CODE -- Hand-written `input_schema` / `output_schema` JSON Schema dicts — these come from type hints now -- `types.ListToolsResult`, `types.CallToolResult`, `types.ListResourcesResult`, `types.ListResourceTemplatesResult`, `types.ReadResourceResult`, `types.ListPromptsResult`, `types.GetPromptResult` — result wrappers FastMCP builds for you -- `types.TextContent`, `types.TextResourceContents`, `types.BlobResourceContents` — return plain Python values instead -- `types.ImageContent` / `types.AudioContent` — `fastmcp.utilities.types.Image` / `Audio` -- `types.Tool`, `types.Resource`, `types.ResourceTemplate`, `types.Prompt`, `types.PromptArgument` — declaration types FastMCP derives -- `types.PromptMessage` — `fastmcp.prompts.Message` -- Note which `mcp_types` imports are still needed afterward; protocol types are unchanged in FastMCP, so surviving imports stay as they are. - -CONTEXT AND SIDE CHANNELS -- `ctx.session.send_log_message(...)` — `ctx.info()` / `ctx.debug()` / `ctx.warning()` / `ctx.error()` on a `fastmcp.Context` parameter -- `ctx.session.report_progress(...)` — `ctx.report_progress()` -- `ctx.request_id`, `ctx.meta`, `ctx.protocol_version` — these live on `ctx.request_context` in FastMCP (`ctx.request_context.request_id`, and so on); note that `ctx.protocol_version` directly on the Context does not exist -- `ctx.params` — no equivalent, and none is needed: the raw request params were how a low-level handler read the tool's arguments, and those are now the decorated function's typed parameters. `ctx.request_context.params` does NOT exist and raises AttributeError. -- Direct `ctx.session` use for anything else — `Context.session` exists in FastMCP too and returns the same raw SDK session, so this still works; prefer a `Context` method where one exists, and note the remaining uses as SDK-coupled - -ERRORS AND AUTH -- `raise ValueError(f"Unknown tool: ...")` dispatch fallbacks — these become unnecessary -- `MCPError` construction and any error-code mapping -- `auth=AuthSettings(...)`, `token_verifier=`, `auth_server_provider=` — one `auth=` provider in FastMCP -- `TransportSecuritySettings` - -For each item found, show the original code, say what it did, and give the FastMCP equivalent. Where several handlers collapse into one decorated function, show the collapse rather than a line-by-line mapping. Call out anything you could not find a documented FastMCP replacement for instead of inventing one. -</Prompt> - -## Install - -FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3: - -```bash -pip install "fastmcp==4.0.0b1" -# or -uv add "fastmcp==4.0.0b1" -``` - -An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). - -FastMCP 4 depends on the MCP SDK v2 you are already using, so `mcp_types` stays importable and every protocol type keeps its current name and fields. Most of those imports vanish from your code anyway — FastMCP derives them — but the ones you keep need no changes. - -## Server and Transport - -The `Server` class asks you to open a transport, connect its streams, build initialization options, and run an event loop. FastMCP collapses that into a constructor and a `run()` call. - -<CodeGroup> - -```python Before test="skip" -import asyncio - -from mcp.server.lowlevel.server import Server -from mcp.server.stdio import stdio_server - -server = Server("my-server") # plus every on_* handler - -async def main(): - async with stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - -asyncio.run(main()) -``` - -```python After -from fastmcp import FastMCP - -mcp = FastMCP("my-server") - -# ... register tools, resources, prompts ... - -if __name__ == "__main__": - mcp.run() -``` - -</CodeGroup> - -Serving HTTP is the same shape. Where the low-level class hands you a Starlette app from `server.streamable_http_app()` and leaves the hosting to you, FastMCP runs it directly: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("my-server") - -if __name__ == "__main__": - mcp.run(transport="http", host="0.0.0.0", port=8000) -``` - -`mcp.http_app()` still returns a Starlette app when you need to mount the server inside a larger application. - -## Tools - -This is where the difference is largest. SDK v2 requires two handlers — one describing your tools with hand-written JSON Schema, one dispatching calls by name — and both are passed to the constructor, so the connection between a tool's declaration and its implementation lives only in your head. FastMCP derives both from the function. - -<CodeGroup> - -```python Before -import mcp_types as types -from mcp.server.context import ServerRequestContext -from mcp.server.lowlevel.server import Server - - -async def list_tools(ctx: ServerRequestContext, params) -> types.ListToolsResult: - number = {"type": "number"} - schema = { - "type": "object", - "properties": {"a": number, "b": number}, - "required": ["a", "b"], - } - return types.ListToolsResult( - tools=[ - types.Tool(name="add", description="Add two numbers", input_schema=schema), - types.Tool( - name="multiply", description="Multiply two numbers", input_schema=schema - ), - ] - ) - - -async def call_tool( - ctx: ServerRequestContext, params: types.CallToolRequestParams -) -> types.CallToolResult: - arguments = params.arguments or {} - if params.name == "add": - result = arguments["a"] + arguments["b"] - elif params.name == "multiply": - result = arguments["a"] * arguments["b"] - else: - raise ValueError(f"Unknown tool: {params.name}") - return types.CallToolResult(content=[types.TextContent(type="text", text=str(result))]) - - -server = Server("math", on_list_tools=list_tools, on_call_tool=call_tool) -``` - -```python After -from fastmcp import FastMCP - -mcp = FastMCP("math") - - -@mcp.tool -def add(a: float, b: float) -> float: - """Add two numbers""" - return a + b - - -@mcp.tool -def multiply(a: float, b: float) -> float: - """Multiply two numbers""" - return a * b -``` - -</CodeGroup> - -Each `@mcp.tool` function is self-contained: its name becomes the tool name, its docstring becomes the description, its annotations become the JSON Schema, and its return value is serialized for you. The dispatch chain, the schema dicts, the `CallToolResult` wrapper, the `TextContent` wrapper, and the unknown-tool fallback all go away — a tool that doesn't exist is now the framework's problem, not a branch you maintain. - -### Type Mapping - -Your hand-written `input_schema` becomes the function's parameters: - -| JSON Schema | Python type | -|---|---| -| `{"type": "string"}` | `str` | -| `{"type": "number"}` | `float` | -| `{"type": "integer"}` | `int` | -| `{"type": "boolean"}` | `bool` | -| `{"type": "array", "items": {"type": "string"}}` | `list[str]` | -| `{"type": "object"}` | `dict` | -| A property absent from `required` | `param: str \| None = None` | - -Constraints carry over too. A schema with `"minimum"` and `"maximum"` becomes a Pydantic `Field`, and a nested object schema becomes a Pydantic model or dataclass used as the annotation — FastMCP generates the same schema back out of it. - -### Return Values - -The low-level class requires tools to return a `CallToolResult` wrapping a list of content blocks. FastMCP takes the value itself — strings, numbers, dicts, lists, dataclasses, Pydantic models — and handles both the content block and the structured output. For images and audio, FastMCP provides wrapper types that carry the format: - -```python -from fastmcp import FastMCP -from fastmcp.utilities.types import Image - -mcp = FastMCP("media") - - -@mcp.tool -def create_chart(data: list[float]) -> Image: - """Generate a chart from data.""" - png_bytes = render_png(data) # your logic - return Image(data=png_bytes, format="png") -``` - -When you need full control over the wire result — multiple content blocks, or structured content that differs from the content blocks — return a `ToolResult` from `fastmcp.tools` instead. - -### Stricter Arguments - -Deriving the schema from your signature also tightens what callers may send, and this is the one behavior change the migration introduces. Your `on_call_tool` handler reads `params.arguments` as a plain dict and never looks at keys it doesn't need, so a call carrying an unexpected key succeeds. FastMCP declares `"additionalProperties": false` on the generated schema and enforces it, so the same call fails: - -```python test="skip" -# Against the low-level handler: succeeds, "extra" never read. -# Against FastMCP: raises, "extra" is not a parameter of greet(). -await client.call_tool("greet", {"name": "World", "extra": "surprise"}) -``` - -For most servers this is an improvement that costs nothing — a caller sending keys your handler never read was already a bug, and the hand-written schema never advertised that they were allowed. It matters if a client in your fleet attaches metadata alongside real arguments, since those calls start failing the moment you migrate. Accept them explicitly as optional parameters if you need to keep them working. - -## Resources - -Resources take three handlers on the low-level class: one to list static resources, one to list URI templates, and one to read whichever URI arrives, with routing you write by hand. FastMCP replaces all three with a decorator per resource, and detects templates from the URI itself. - -<CodeGroup> - -```python Before -import json - -import mcp_types as types -from mcp.server.context import ServerRequestContext -from mcp.server.lowlevel.server import Server - - -async def list_resources(ctx: ServerRequestContext, params) -> types.ListResourcesResult: - return types.ListResourcesResult( - resources=[ - types.Resource( - uri="config://app", - name="app_config", - description="Application configuration", - mime_type="application/json", - ) - ] - ) - - -async def list_resource_templates( - ctx: ServerRequestContext, params -) -> types.ListResourceTemplatesResult: - return types.ListResourceTemplatesResult( - resource_templates=[ - types.ResourceTemplate( - uri_template="users://{user_id}/profile", - name="user_profile", - description="User profile by ID", - ) - ] - ) - - -async def read_resource( - ctx: ServerRequestContext, params: types.ReadResourceRequestParams -) -> types.ReadResourceResult: - uri = str(params.uri) - if uri == "config://app": - text = json.dumps({"debug": False, "version": "1.0"}) - elif uri.startswith("users://"): - user_id = uri.split("/")[2] - text = json.dumps({"id": user_id, "name": f"User {user_id}"}) - else: - raise ValueError(f"Unknown resource: {uri}") - return types.ReadResourceResult( - contents=[ - types.TextResourceContents( - uri=params.uri, mime_type="application/json", text=text - ) - ] - ) - - -server = Server( - "data", - on_list_resources=list_resources, - on_list_resource_templates=list_resource_templates, - on_read_resource=read_resource, -) -``` - -```python After -import json - -from fastmcp import FastMCP - -mcp = FastMCP("data") - - -@mcp.resource("config://app", mime_type="application/json") -def app_config() -> str: - """Application configuration""" - return json.dumps({"debug": False, "version": "1.0"}) - - -@mcp.resource("users://{user_id}/profile", mime_type="application/json") -def user_profile(user_id: str) -> str: - """User profile by ID""" - return json.dumps({"id": user_id, "name": f"User {user_id}"}) -``` - -</CodeGroup> - -The URI does the routing. A `{placeholder}` in the URI makes the resource a template, and FastMCP matches the parameter to the function argument of the same name — so the `uri.split("/")[2]` parsing goes away along with the handler that held it. Return a `str` for text content and `bytes` for binary; FastMCP builds the `TextResourceContents` or `BlobResourceContents` wrapper. - -Templated resources also gain a protection the low-level version left to you: FastMCP screens extracted parameter values for path traversal, absolute paths, and null bytes before your function runs. See [Path Security](/servers/resources#path-security) if a template legitimately accepts those values. - -## Prompts - -The same collapse, one more time: `on_list_prompts` declares arguments as `PromptArgument` objects, `on_get_prompt` routes by name and assembles a `GetPromptResult` of `PromptMessage` objects. FastMCP takes a function whose parameters are the arguments. - -<CodeGroup> - -```python Before -import mcp_types as types -from mcp.server.context import ServerRequestContext -from mcp.server.lowlevel.server import Server - - -async def list_prompts(ctx: ServerRequestContext, params) -> types.ListPromptsResult: - return types.ListPromptsResult( - prompts=[ - types.Prompt( - name="review_code", - description="Review code for issues", - arguments=[ - types.PromptArgument( - name="code", description="The code to review", required=True - ), - types.PromptArgument( - name="language", description="Programming language", required=False - ), - ], - ) - ] - ) - - -async def get_prompt( - ctx: ServerRequestContext, params: types.GetPromptRequestParams -) -> types.GetPromptResult: - if params.name != "review_code": - raise ValueError(f"Unknown prompt: {params.name}") - arguments = params.arguments or {} - language = arguments.get("language", "") - note = f" (written in {language})" if language else "" - text = f"Please review this code{note}:\n\n{arguments.get('code', '')}" - return types.GetPromptResult( - description="Code review prompt", - messages=[ - types.PromptMessage( - role="user", content=types.TextContent(type="text", text=text) - ) - ], - ) - - -server = Server("prompts", on_list_prompts=list_prompts, on_get_prompt=get_prompt) -``` - -```python After -from fastmcp import FastMCP - -mcp = FastMCP("prompts") - - -@mcp.prompt -def review_code(code: str, language: str | None = None) -> str: - """Review code for issues""" - note = f" (written in {language})" if language else "" - return f"Please review this code{note}:\n\n{code}" -``` - -</CodeGroup> - -Returning a `str` wraps it as a single user message. Whether an argument is required is read from the signature: `code` has no default, so it's required; `language` defaults to `None`, so it isn't. Multi-turn prompts return a list of `Message` objects, which take their text positionally and default to the user role: - -```python -from fastmcp import FastMCP -from fastmcp.prompts import Message - -mcp = FastMCP("prompts") - - -@mcp.prompt -def debug_session(error: str) -> list[Message]: - """Start a debugging conversation""" - return [ - Message(f"I'm seeing this error:\n\n{error}"), - Message("I'll help you debug that. Can you share the relevant code?", role="assistant"), - ] -``` - -## Request Context - -The low-level class hands each handler a `ServerRequestContext` carrying the raw `ServerSession`, and you reach through it to send notifications. FastMCP injects a typed `Context` into any function that declares one, and puts the operations you actually want on it directly. - -<CodeGroup> - -```python Before -import mcp_types as types -from mcp.server.context import ServerRequestContext -from mcp.server.lowlevel.server import Server - - -async def call_tool( - ctx: ServerRequestContext, params: types.CallToolRequestParams -) -> types.CallToolResult: - if params.name == "process_data": - await ctx.session.send_log_message(level="info", data="Starting processing...") - await ctx.session.report_progress(1, 2) - # ... do work ... - await ctx.session.send_log_message(level="info", data="Done!") - return types.CallToolResult( - content=[types.TextContent(type="text", text="Processed")] - ) - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server("worker", on_call_tool=call_tool) -``` - -```python After -from fastmcp import FastMCP, Context - -mcp = FastMCP("worker") - - -@mcp.tool -async def process_data(ctx: Context) -> str: - """Process data with progress logging""" - await ctx.info("Starting processing...") - await ctx.report_progress(1, 2) - # ... do work ... - await ctx.info("Done!") - return "Processed" -``` - -</CodeGroup> - -The `Context` parameter is injected by type annotation and never appears in the tool's schema, so clients see `process_data` as taking no arguments. Beyond logging and progress, it carries resource reads, [session state](/servers/sessions), elicitation, and component visibility — see [Context](/servers/context) for the full surface. - -One thing to check as you migrate: `ctx.session` still exists on a FastMCP `Context` as an escape hatch, and it hands back the same raw SDK session your handlers use today. That makes it a working translation for anything with no `Context` equivalent — but it's also the one part of your server that stays coupled to SDK internals, so reach for the `Context` method first and keep the escape hatch for what genuinely has no equivalent. - -## Complete Example - -Everything above, applied at once: - -<CodeGroup> - -```python Before expandable -import json - -import mcp_types as types -from mcp.server.context import ServerRequestContext -from mcp.server.lowlevel.server import Server - - -async def list_tools(ctx: ServerRequestContext, params) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="greet", - description="Greet someone by name", - input_schema={ - "type": "object", - "properties": {"name": {"type": "string"}}, - "required": ["name"], - }, - ) - ] - ) - - -async def call_tool( - ctx: ServerRequestContext, params: types.CallToolRequestParams -) -> types.CallToolResult: - if params.name == "greet": - name = (params.arguments or {})["name"] - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Hello, {name}!")] - ) - raise ValueError(f"Unknown tool: {params.name}") - - -async def list_resources(ctx: ServerRequestContext, params) -> types.ListResourcesResult: - return types.ListResourcesResult( - resources=[ - types.Resource( - uri="info://version", name="version", description="Server version" - ) - ] - ) - - -async def read_resource( - ctx: ServerRequestContext, params: types.ReadResourceRequestParams -) -> types.ReadResourceResult: - if str(params.uri) != "info://version": - raise ValueError(f"Unknown resource: {params.uri}") - return types.ReadResourceResult( - contents=[ - types.TextResourceContents( - uri=params.uri, text=json.dumps({"version": "1.0.0"}) - ) - ] - ) - - -async def list_prompts(ctx: ServerRequestContext, params) -> types.ListPromptsResult: - return types.ListPromptsResult( - prompts=[ - types.Prompt( - name="summarize", - description="Summarize text", - arguments=[types.PromptArgument(name="text", required=True)], - ) - ] - ) - - -async def get_prompt( - ctx: ServerRequestContext, params: types.GetPromptRequestParams -) -> types.GetPromptResult: - if params.name != "summarize": - raise ValueError(f"Unknown prompt: {params.name}") - text = (params.arguments or {}).get("text", "") - return types.GetPromptResult( - description="Summarize text", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Summarize:\n\n{text}"), - ) - ], - ) - - -server = Server( - "demo", - on_list_tools=list_tools, - on_call_tool=call_tool, - on_list_resources=list_resources, - on_read_resource=read_resource, - on_list_prompts=list_prompts, - on_get_prompt=get_prompt, -) -``` - -```python After -import json - -from fastmcp import FastMCP - -mcp = FastMCP("demo") - - -@mcp.tool -def greet(name: str) -> str: - """Greet someone by name""" - return f"Hello, {name}!" - - -@mcp.resource("info://version") -def version() -> str: - """Server version""" - return json.dumps({"version": "1.0.0"}) - - -@mcp.prompt -def summarize(text: str) -> str: - """Summarize text""" - return f"Summarize:\n\n{text}" - - -if __name__ == "__main__": - mcp.run() -``` - -</CodeGroup> - -## What You Gain - -Deleting the handler machinery is the immediate payoff, but the reason to make this move is what becomes available once your server is a FastMCP server. - -[Server composition](/servers/composition) mounts one server inside another, so a surface that grew unwieldy as a single dispatch chain splits into modules developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need — the cross-cutting concerns that, on the low-level class, meant threading the same code through every handler. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control, and the [OpenAPI integration](/integrations/openapi) generates an entire server from an API specification you already have. [Authentication](/servers/auth/authentication) consolidates the SDK's separate token verifier, authorization-server provider, and `AuthSettings` into a single `auth=` provider, with named providers for GitHub, Google, Auth0, Keycloak, and others. - -The change most likely to affect your daily work is [testing](/servers/testing). FastMCP ships a client that connects to a server object in the same Python process, so a test calls your tools directly — no subprocess, no stdio pipes, no transport to stand up. diff --git a/docs/v3/getting-started/upgrading/from-low-level-sdk.mdx b/docs/getting-started/upgrading/from-low-level-sdk.mdx similarity index 92% rename from docs/v3/getting-started/upgrading/from-low-level-sdk.mdx rename to docs/getting-started/upgrading/from-low-level-sdk.mdx index ce4ddea75..ab49f1574 100644 --- a/docs/v3/getting-started/upgrading/from-low-level-sdk.mdx +++ b/docs/getting-started/upgrading/from-low-level-sdk.mdx @@ -9,16 +9,18 @@ If you've been building MCP servers directly on the `mcp` package's `Server` cla The core idea: instead of telling the SDK what your tools look like and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The plumbing you wrote to satisfy the protocol just disappears. -<Note> -This guide covers upgrading from **v1** of the `mcp` package. We'll provide a separate guide when v2 ships. -</Note> +## Why now is the moment to switch + +MCP SDK v2 landed sweeping breaking changes on the low-level `Server`: the protocol types moved out of `mcp.types` into a separate `mcp_types` package, every field was renamed from camelCase to snake_case, the `Server` class was rebuilt, `McpError` was renamed, and sessions were removed on the new sessionless protocol era. If you build directly on the low-level SDK, all of that lands on you — you have to rewrite your imports, your handler signatures, and your error construction to match the new surface. + +Adopting FastMCP is the easier path. FastMCP 4 runs on SDK v2 and hides that entire surface behind a high-level API that did not change. You write `@mcp.tool` and never touch the renamed internals — FastMCP derives the protocol layer from your function signatures, so the SDK v2 rename simply isn't something your code has to know about. Migrating low-level-SDK-v1 code to FastMCP is less work than migrating it to raw SDK v2, and you come out the other side with the whole framework: composition, middleware, proxies, authentication, and testing. The SDK v2 break is the natural moment to make the jump. <Note> Already using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is simpler — see the [FastMCP 1.0 upgrade guide](/getting-started/upgrading/from-mcp-sdk) instead. </Note> <Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance."> -You are upgrading an MCP server from the `mcp` package's low-level Server class (v1) to FastMCP 3.0. The server currently uses `mcp.server.Server` (or `mcp.server.lowlevel.server.Server`) with manual handler registration. Analyze the provided code and rewrite it using FastMCP's high-level API. The full guide is at https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. +You are upgrading an MCP server from the `mcp` package's low-level Server class (v1) to FastMCP 4. The server currently uses `mcp.server.Server` (or `mcp.server.lowlevel.server.Server`) with manual handler registration. Analyze the provided code and rewrite it using FastMCP's high-level API. The full guide is at https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. UPGRADE RULES: diff --git a/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx b/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx deleted file mode 100644 index ec5ac5b7e..000000000 --- a/docs/getting-started/upgrading/from-mcp-sdk-v1.mdx +++ /dev/null @@ -1,264 +0,0 @@ ---- -title: Upgrading from MCP SDK v1 -sidebarTitle: "From MCP SDK v1" -description: Upgrade from FastMCP 1.0, bundled in v1 of the MCP Python SDK, to the standalone FastMCP framework -icon: up ---- - -If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 — the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.** - -```python test="skip" -# Before -from mcp.server.fastmcp import FastMCP - -# After -from fastmcp import FastMCP -``` - -That's it. Your `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` decorators, your `mcp.run()` call, and the rest of your server code all work as-is. - -<Tip> -**Why upgrade?** FastMCP 1.0 pioneered the Pythonic MCP server experience, and we're proud it was bundled into the `mcp` package. The standalone FastMCP project has since grown into a full framework for taking MCP servers from prototype to production — with composition, middleware, proxy servers, authentication, and much more. Upgrading gives you access to all of that, plus ongoing updates and fixes. -</Tip> - -## The SDK v2 Transition - -MCP SDK v2 is a substantial, deliberate modernization of the protocol layer, and part of that work rebuilt the high-level server as `MCPServer` under `mcp.server.mcpserver`. `mcp.server.fastmcp` does not exist there — so a FastMCP 1.0 server meets the change the moment its environment resolves `mcp` to v2: - -``` -ModuleNotFoundError: No module named 'mcp.server.fastmcp' -``` - -Often nobody chose that moment. An unpinned `mcp` dependency, a fresh lockfile, or a rebuilt container picks up the new major version and the module your server imports on line one has moved. Nothing is wrong with your code, and nothing is wrong with the SDK — major versions are exactly where a change like this belongs. Your build just crossed it earlier than you planned to. - -Pinning the SDK back restores the old module immediately, with no code changes, and buys you time to choose deliberately: - -```bash -pip install "mcp<2" -``` - -## Two Upgrade Paths - -From here, both directions are reasonable, and which is less work depends on which API you already write. - -**`MCPServer`, the SDK's high-level server**, is a capable, well-designed API and the direct continuation of the SDK's own line. Because it was rebuilt rather than renamed, expect real work: a new class and import, a different decorator call style, and protocol types imported from the standalone `mcp_types` package with snake_case field names. - -**FastMCP** is the import change at the top of this page. It is short for a specific, historical reason: FastMCP 1.0 *is* early FastMCP — it was contributed into the `mcp` package, and the standalone project kept developing that same high-level API. The surface you already write against is the surface FastMCP still offers. FastMCP 4 is itself built on MCP SDK v2, so both paths land you on the same modern protocol layer; FastMCP absorbs the adaptation internally rather than asking your code to do it. - -The claim is narrower than it may sound. It holds for FastMCP 1.0 servers specifically, because of shared lineage — not because one library is better than the other. Both projects are moving the same direction on the same protocol. - -If you have already moved to SDK v2 and write against `MCPServer` today, see [Upgrading from MCP SDK v2](/getting-started/upgrading/from-mcp-sdk-v2). If your server uses the low-level `Server` class rather than the high-level one, see [Upgrading from the Low-Level SDK v1](/getting-started/upgrading/from-low-level-sdk-v1). - -## Install - -FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3: - -```bash -pip install "fastmcp==4.0.0b1" -# or -uv add "fastmcp==4.0.0b1" -``` - -An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). - -FastMCP depends on the `mcp` package, so the SDK stays installed and importable. What changes is which parts of it you reach for. FastMCP 4 builds on SDK v2, where `mcp.server.fastmcp` is gone — anything you imported from it needs a new home, and the sections below cover that. `mcp.types` still resolves (it aliases the standalone `mcp_types` package), though its fields are snake_case now. Update your import, run your server, and if your tools work, you're done. - -<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance."> -You are upgrading an MCP server from FastMCP 1.0 (bundled in v1 of the `mcp` package) to standalone FastMCP 4. - -FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1 — it explains every item below, with the replacement code. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs. - -For most servers the entire upgrade is the first item. Work through the rest looking for signals, and report only what you actually find. - -THE IMPORT (every server needs this) -- `from mcp.server.fastmcp import FastMCP` → `from fastmcp import FastMCP` -- `from mcp.server.fastmcp import Context` -- `from mcp.server.fastmcp import Image` - -CONSTRUCTOR ARGUMENTS THAT MOVED (all raise TypeError) -- moved to run()/http_app(), and FastMCP names them in the error: host, port, log_level, debug, sse_path, message_path, streamable_http_path, json_response, stateless_http -- moved but rejected with only a generic "unexpected keyword argument", so flag these explicitly: `event_store=` (→ `http_app(event_store=...)`; dropping it silently disables streamable-HTTP resumability), `mount_path=` (→ `http_app(path=...)`), `transport=` (→ `run(transport=...)`), `transport_security=` (→ host/origin settings on `http_app()`), `warn_on_duplicate_tools/_resources/_prompts=` (→ one `on_duplicate=`), `dependencies=` (→ a fastmcp.json file) -- `name`, `instructions`, `website_url`, `icons`, `tools`, `lifespan` carry over unchanged -- note when reporting: FastMCP names the streamable HTTP transport "http", not "streamable-http" - -CONTEXT METHODS WITH CHANGED SIGNATURES (compile fine, fail at runtime) -- `ctx.log(level, data)` → `ctx.log(message, level=...)`, message first -- `ctx.info(data)` / `debug` / `warning` / `error` → take a str message, not arbitrary JSON-serializable data -- `ctx.elicit(..., schema=Model)` → `response_type=Model` -- `ctx.read_resource(uri)` → returns a `ResourceResult`; read `.contents` rather than iterating the return value -- `ctx.report_progress`, `ctx.request_id`, `ctx.client_id` are unchanged - -AUTHENTICATION (the one case where the single import change is NOT enough) -- `token_verifier=` and `auth_server_provider=` — both raise TypeError on FastMCP 4 -- `auth=AuthSettings(...)` — the keyword survives but the value does not: FastMCP's `auth=` takes a FastMCP `AuthProvider`, not the SDK settings object -Report these as a real migration, not a rename: FastMCP consolidates all three into one provider, and ships `JWTVerifier` for tokens you already issue, `RemoteAuthProvider` for delegating to an external authorization server, `OAuthProxy` for wrapping a provider without Dynamic Client Registration, and named providers for GitHub, Google, Auth0, Keycloak, and others. Look up the right one at https://gofastmcp.com/servers/auth/authentication rather than guessing. - -PROMPT RETURN VALUES -- prompt functions returning `PromptMessage`, or `TextContent`-wrapped content -- prompt functions returning raw dicts with "role"/"content" keys — FastMCP 1.0 coerced these silently, standalone FastMCP does not - -OTHER mcp.* IMPORTS -- anything from `mcp.types` — the import path still works in the SDK v2 that FastMCP 4 builds on, but the fields were renamed from camelCase to snake_case -- `from mcp.server.stdio import stdio_server` and any transport boilerplate around it -- `mcp.types.TextContent` / `ImageContent` used to wrap tool return values — FastMCP has friendlier equivalents, so prefer those over keeping the raw protocol types - -DECORATOR RETURN VALUES -- any code reading `.name`, `.description`, or other component attributes off a `@mcp.tool` / `@mcp.resource` / `@mcp.prompt` decorated function. Decorators return the original function now. - -For each item found, show the original line, name what changed, and give the corrected code from the guide. If the only change needed is the import, say so plainly rather than manufacturing work. -</Prompt> - -## What Might Need Updating - -Most servers need nothing beyond the import change. Skim the sections below to see if any apply. - -### Constructor Settings - -If you passed transport settings like `host` or `port` directly to `FastMCP()`, those now belong on `run()`. This keeps your server definition independent of how it's deployed: - -```python test="skip" -from fastmcp import FastMCP - -# Before -mcp = FastMCP("my-server", host="0.0.0.0", port=8080) -mcp.run() - -# After -mcp = FastMCP("my-server") -mcp.run(transport="http", host="0.0.0.0", port=8080) -``` - -Nine arguments move this way, and each raises a `TypeError` naming its own replacement, so you can also just run the server and follow the errors: `host`, `port`, `log_level`, `debug`, `sse_path`, `message_path`, `streamable_http_path`, `json_response`, and `stateless_http`. - -A second group is rejected with only a generic "unexpected keyword argument" and no hint, which makes these the ones worth reading in advance: - -| SDK v1 `FastMCP(...)` | FastMCP 4 | -|---|---| -| `event_store=` | `mcp.http_app(event_store=...)` | -| `mount_path=` | `mcp.http_app(path=...)` | -| `transport=` | `mcp.run(transport=...)` | -| `transport_security=` | `host_origin_protection=`, `allowed_hosts=`, `allowed_origins=` on `http_app()` | -| `warn_on_duplicate_tools=`, `_resources=`, `_prompts=` | a single `on_duplicate=` | -| `dependencies=[...]` | a [`fastmcp.json`](/deployment/server-configuration) configuration file | -| `auth_server_provider=`, `token_verifier=` | a single `auth=` provider — see [Authentication](#authentication) below | - -Dropping `event_store=` rather than moving it is the one to watch: it silently disables streamable-HTTP resumability, so a client that reconnects loses the events it missed instead of replaying them. - -`name`, `instructions`, `website_url`, `icons`, `tools`, and `lifespan` carry over to the constructor unchanged. - -### Authentication - -This is the one case where the import change alone won't do. FastMCP 1.0 exposed the SDK's auth plumbing as three separate constructor arguments — `token_verifier=`, `auth_server_provider=`, and `auth=AuthSettings(...)`. The first two raise `TypeError` on FastMCP 4, and while `auth=` survives as a keyword, its value doesn't: FastMCP expects one of its own `AuthProvider` objects rather than the SDK's settings object. - -The replacement is a single provider carrying the whole configuration, chosen by what you're actually doing: - -| What you were doing | FastMCP provider | -|---|---| -| Validating JWTs you already issue | `JWTVerifier` | -| Delegating to an external authorization server | `RemoteAuthProvider` | -| Wrapping a provider without Dynamic Client Registration | `OAuthProxy` | -| GitHub, Google, Auth0, Keycloak, WorkOS, … | the matching named provider | - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import JWTVerifier - -mcp = FastMCP("my-server", auth=JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json")) -``` - -See [Authentication](/servers/auth/authentication) for the full set and their configuration. - -### Context Methods - -`from fastmcp import Context` gets you the injected context object, but four of its methods took a different shape in FastMCP 1.0, and a bare import swap leaves calls that compile and then fail: - -| SDK v1 | FastMCP 4 | -|---|---| -| `ctx.log(level, data)` | `ctx.log(message, level=...)` — message is first now | -| `ctx.info(data)` and its `debug`/`warning`/`error` siblings | take a `str` message, where v1 accepted any JSON-serializable value | -| `ctx.elicit(message, schema=Model)` | `ctx.elicit(message, response_type=Model)` | -| `ctx.read_resource(uri)` | returns a `ResourceResult`; the payload is under `.contents` rather than being iterable directly | - -`ctx.report_progress()`, `ctx.request_id`, and `ctx.client_id` are unchanged. - -### Prompts - -If your prompt functions return `mcp.types.PromptMessage` objects or raw dicts with `role`/`content` keys, upgrade them to FastMCP's `Message` class. Or just return a plain string — it's automatically wrapped as a user message. FastMCP 1.0 silently coerced dicts into messages; standalone FastMCP requires typed `Message` objects or strings. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("prompts") - -@mcp.prompt -def review(code: str) -> str: - """Review code for issues""" - return f"Please review this code:\n\n{code}" -``` - -Multi-turn prompts return a list of messages. `Message` takes the text positionally and defaults to the user role, so only the assistant turns need a `role`: - -```python -from fastmcp import FastMCP -from fastmcp.prompts import Message - -mcp = FastMCP("prompts") - -@mcp.prompt -def debug(error: str) -> list[Message]: - """Start a debugging session""" - return [ - Message(f"I'm seeing this error:\n\n{error}"), - Message("I'll help debug that. Can you share the relevant code?", role="assistant"), - ] -``` - -### Other `mcp.*` Imports - -FastMCP 4 builds on MCP SDK v2, which moved the protocol types into a standalone `mcp_types` package and re-exports it as `mcp.types` — so `from mcp.types import X` keeps working. The field names did change, from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). For everything else SDK v2 changed, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3), which covers the same protocol rebuild from the FastMCP side. - -Where FastMCP provides its own API for the same thing, it's worth switching over rather than importing the protocol type: - -| MCP SDK v1 | FastMCP equivalent | -|---|---| -| `mcp.types.TextContent(type="text", text=str(x))` | Just return `x` from your tool | -| `mcp.types.ImageContent(...)` | `from fastmcp.utilities.types import Image` | -| `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` | -| `mcp.server.fastmcp.Context` | `from fastmcp import Context` | -| `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport | - -For protocol types without a FastMCP equivalent, import them from `mcp_types` directly. - -### Decorated Functions - -In FastMCP 1.0, `@mcp.tool` replaced your function with a `FunctionTool` object. Now decorators return your original function unchanged, so decorated functions stay callable for testing, reuse, and composition: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("greeter") - -@mcp.tool -def greet(name: str) -> str: - """Greet someone""" - return f"Hello, {name}!" - -# This works now — the function is still a regular function -assert greet("World") == "Hello, World!" -``` - -Code that reads `.name`, `.description`, or other component attributes off the decorated result needs updating. This is uncommon — most servers never touch the tool object. When you do need the component itself, reach it through the server with `await mcp.get_tool("greet")`. - -## Verifying the Upgrade - -Run your server the way you always have. To confirm every component came across, inspect the server with the FastMCP CLI: - -```bash -fastmcp inspect my_server.py -``` - -The output lists every tool, resource, template, and prompt your server exposes, so a component that failed to register shows up here rather than at the first client call. - -## Looking Ahead - -The MCP ecosystem is evolving fast. Part of FastMCP's job is to absorb that complexity on your behalf — as the protocol and its tooling grow, we do the work so your server code doesn't have to change. The SDK v1 to v2 transition is the clearest example so far: an entire protocol layer was rewritten underneath FastMCP 4, and the servers on this page cross it with one line. diff --git a/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx b/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx deleted file mode 100644 index e25489005..000000000 --- a/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx +++ /dev/null @@ -1,328 +0,0 @@ ---- -title: Upgrading from MCP SDK v2 -sidebarTitle: "From MCP SDK v2" -description: Move a server built on the MCP Python SDK v2's MCPServer class to FastMCP -icon: up ---- - -If your server starts with `from mcp.server.mcpserver import MCPServer`, you're using the high-level server API introduced in v2 of the `mcp` package. Moving to FastMCP is a mechanical migration: the two APIs share a lineage, so most of your code carries over with a rename. - -```python -# Before -from mcp.server.mcpserver import MCPServer - -server = MCPServer("my-server") - -@server.tool() -def greet(name: str) -> str: - """Greet someone by name""" - return f"Hello, {name}!" - -# After -from fastmcp import FastMCP - -mcp = FastMCP("my-server") - -@mcp.tool -def greet(name: str) -> str: - """Greet someone by name""" - return f"Hello, {name}!" -``` - -That resemblance is not a coincidence. `MCPServer` is the SDK's successor to FastMCP 1.0, the high-level server that shipped inside SDK v1; FastMCP is the standalone framework that grew from the same starting point. Both derive the protocol layer from your function signatures — type hints become JSON Schema, docstrings become descriptions, return values are serialized for you. What separates them is scope: `MCPServer` is the SDK's ergonomic surface over the protocol, while FastMCP builds on that same SDK v2 and adds the machinery a server needs in production — composition, middleware, proxying, authentication providers, tool transformation, a client, and a testing story. - -<Note> -Building on the low-level `Server` class instead? See [Upgrading from the Low-Level SDK v2](/getting-started/upgrading/from-low-level-sdk-v2). Still on SDK v1's `mcp.server.fastmcp.FastMCP`? Your upgrade is a single import — see [Upgrading from MCP SDK v1](/getting-started/upgrading/from-mcp-sdk-v1). -</Note> - -<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance."> -You are migrating an MCP server from the MCP Python SDK v2's high-level `MCPServer` class (`mcp.server.mcpserver`) to FastMCP 4. The two APIs are close relatives, so most of this is mechanical renaming. - -FIRST, fetch https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2 — it carries the full mapping table and before-and-after code for everything below. Fetch https://gofastmcp.com for anything the guide doesn't cover. Do not invent a FastMCP API you have not confirmed in the docs. - -Then work through the provided code looking for each of these. - -IMPORTS AND CONSTRUCTION -- `MCPServer`, and `Context`, `Image`, `Audio`, `Message` imported from `mcp.server.mcpserver` -- `mcp_types` imports — these are UNCHANGED. FastMCP 4 builds on the same SDK v2, so leave them alone and say so. - -DECORATORS -- `@server.tool()`, `@server.prompt()` — FastMCP takes a bare `@mcp.tool` / `@mcp.prompt` (and still accepts the called form) -- `@server.resource(...)`, `@server.completion()`, `@server.custom_route(...)` - -TRANSPORT -- `run(transport="streamable-http")` — FastMCP names this transport "http" -- `streamable_http_app()`, `sse_app()` - -CONSTRUCTOR ARGUMENTS THAT DO NOT CARRY OVER -- `debug=`, `log_level=` -- `warn_on_duplicate_tools=` / `_resources=` / `_prompts=` -- `dependencies=` -- `title=`, `description=` -- `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` — FastMCP consolidates all three into one `auth=` provider -- `cache_hints=` -- `extensions=` -- `tools=[...]` (rare — the SDK's `Tool` type is not exported): FastMCP takes plain callables, so pass the underlying functions -These raise TypeError, most naming their replacement. `name`, `version`, `instructions`, `icons`, `website_url`, `lifespan`, `resource_security`, and `request_state_security` carry over unchanged. - -CONTEXT — these ten properties do NOT exist on FastMCP's Context and raise AttributeError if you only swap the import: -- `ctx.mcp_server` → `ctx.fastmcp` -- `ctx.headers` → `get_http_headers()` from `fastmcp.server.dependencies` (a function, not a property) -- `ctx.protocol_version` → `ctx.request_context.protocol_version` -- `ctx.client_capabilities` → read it off `ctx.session` / `ctx.request_context` -- `ctx.notify_tools_changed()`, `notify_resources_changed()`, `notify_prompts_changed()`, `notify_resource_updated()` → `ctx.send_notification(...)` with the matching `mcp_types` notification. FastMCP emits the list-changed ones for you when components change visibility through `ctx.enable_components` / `ctx.disable_components`. -- `ctx.elicit_url` → not the same thing as `ctx.elicit` (that one is form elicitation, with a different signature and wire behavior). The URL flow survives on the raw session as `ctx.session.elicit_url(...)` — use that rather than deleting an OAuth or payment handoff. -- `ctx.close_standalone_sse_stream` → no public FastMCP equivalent, and NOT on `ctx.request_context`. Flag it for the user. -These four exist on both but with DIFFERENT signatures, so a bare import swap compiles and then fails at runtime: -- `ctx.log(level, data)` → `ctx.log(message, level=...)` — the first positional argument is now the message, not the level -- `ctx.info(data)` / `debug` / `warning` / `error` → these take `message` as a string, where the SDK accepted any JSON-serializable `data` -- `ctx.elicit(message, schema=Model)` → `ctx.elicit(message, response_type=Model)` — the keyword was renamed -- `ctx.read_resource(uri)` → still takes a URI, but returns a `ResourceResult` whose payload is under `.contents`, where the SDK returned an iterable of content objects directly. Code that iterates or indexes the return value needs updating. - -Genuinely unchanged: `report_progress`, `request_id`, `client_id`, `input_responses`, `request_state`, `session`, and `request_context`. - -RESOLVERS — the one part that is not a rename, so check for it first -- any `Annotated[T, Resolve(fn)]` parameter, and the resolvers behind it -- resolvers returning `Elicit[...]`, `Sample`, or `ListRoots` -FastMCP has no resolver injection, but the underlying requests survive in a different shape: on a modern connection `Elicit`, `Sample`, and `ListRoots` all ride the guard pattern, where the tool returns an `InputRequiredResult` and the client answers on the next call. Do not tell the user these capabilities are simply unavailable. Flag every resolver with the guide's per-capability reasoning (server-side LLM call is usually better than guard-routed sampling; roots are often simplest as ordinary tool arguments) rather than picking a rewrite yourself. Also note that a resolved parameter is hidden from the tool's input schema, so replacing it with an ordinary argument changes the schema clients see. - -For each item found, show the original code, name what changed, and give the FastMCP equivalent from the guide. Call out anything you could not find a documented replacement for instead of inventing one. -</Prompt> - -## Install - -FastMCP 4 is in prerelease, so pin the exact version rather than installing unqualified — a bare `pip install fastmcp` or `uv add fastmcp` resolves to the latest *stable* release, which today is FastMCP 3: - -```bash -pip install "fastmcp==4.0.0b1" -# or -uv add "fastmcp==4.0.0b1" -``` - -An exact version pin installs even though it's a prerelease — neither installer needs `--pre` or `--prerelease allow` for a version this specific, only for an open-ended range. For a reproducible lockfile that also pins the prerelease protocol dependencies, see [Install the v4 Prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). - -FastMCP 4 depends on the MCP SDK v2, so nothing you already import from `mcp_types` moves. That is the practical benefit of migrating at this version rather than an earlier one: you and FastMCP are on the same protocol layer, with the same snake_case field names and the same type package, so the migration touches only the server API. - -## The Mechanical Part - -Most of the work is renaming. This table covers the surfaces a typical `MCPServer` server touches: - -| MCP SDK v2 | FastMCP | -|---|---| -| `from mcp.server.mcpserver import MCPServer` | `from fastmcp import FastMCP` | -| `from mcp.server.mcpserver import Context` | `from fastmcp import Context` | -| `from mcp.server.mcpserver import Image, Audio` | `from fastmcp.utilities.types import Image, Audio` | -| `from mcp.server.mcpserver.prompts.base import Message` | `from fastmcp.prompts import Message` | -| `@server.tool()` | `@mcp.tool` | -| `@server.prompt()` | `@mcp.prompt` | -| `@server.resource("uri://x")` | `@mcp.resource("uri://x")` | -| `@server.completion()` | `@mcp.completion` | -| `@server.custom_route(path, methods)` | `@mcp.custom_route(path, methods)` | -| `server.run(transport="streamable-http")` | `mcp.run(transport="http")` | -| `server.streamable_http_app()` | `mcp.http_app()` | -| `server.sse_app()` | `mcp.http_app(transport="sse")` | -| `ctx.mcp_server` | `ctx.fastmcp` | -| `ctx.headers` | `get_http_headers()` from `fastmcp.server.dependencies` | -| `ctx.protocol_version` | `ctx.request_context.protocol_version` | -| `ctx.client_capabilities` | read it off `ctx.session` | -| `from mcp_types import X` | unchanged | - -Two of these are worth a sentence each. The decorators lose their parentheses: `MCPServer` required `@server.tool()` and raised a `TypeError` telling you so if you wrote `@server.tool`, while FastMCP accepts both forms, so `@mcp.tool` is the idiomatic spelling and `@mcp.tool()` keeps working if you'd rather not touch every line. And the streamable HTTP transport is named `"http"` in FastMCP rather than `"streamable-http"` — the transport is the same, and `mcp.run()` still defaults to stdio. - -Here is a complete server before and after. Nothing in the logic changes: - -<CodeGroup> - -```python Before -import json -from mcp.server.mcpserver import MCPServer, Context - -server = MCPServer("demo") - -@server.tool() -def greet(name: str) -> str: - """Greet someone by name""" - return f"Hello, {name}!" - -@server.tool() -async def process(items: list[str], ctx: Context) -> str: - """Process a batch of items""" - for i, item in enumerate(items): - await ctx.report_progress(i, len(items)) - return f"Processed {len(items)} items" - -@server.resource("config://app", mime_type="application/json") -def app_config() -> str: - """Application configuration""" - return json.dumps({"debug": False}) - -@server.resource("users://{user_id}/profile") -def profile(user_id: str) -> str: - """User profile by ID""" - return json.dumps({"id": user_id}) - -@server.prompt() -def summarize(text: str) -> str: - """Summarize text""" - return f"Summarize:\n\n{text}" - -if __name__ == "__main__": - server.run(transport="streamable-http") -``` - -```python After -import json -from fastmcp import FastMCP, Context - -mcp = FastMCP("demo") - -@mcp.tool -def greet(name: str) -> str: - """Greet someone by name""" - return f"Hello, {name}!" - -@mcp.tool -async def process(items: list[str], ctx: Context) -> str: - """Process a batch of items""" - for i, item in enumerate(items): - await ctx.report_progress(i, len(items)) - return f"Processed {len(items)} items" - -@mcp.resource("config://app", mime_type="application/json") -def app_config() -> str: - """Application configuration""" - return json.dumps({"debug": False}) - -@mcp.resource("users://{user_id}/profile") -def profile(user_id: str) -> str: - """User profile by ID""" - return json.dumps({"id": user_id}) - -@mcp.prompt -def summarize(text: str) -> str: - """Summarize text""" - return f"Summarize:\n\n{text}" - -if __name__ == "__main__": - mcp.run(transport="http") -``` - -</CodeGroup> - -## Constructor Arguments - -`FastMCP()` describes your server's identity and behavior; how it gets deployed is decided when you serve it. Several `MCPServer` constructor arguments move accordingly, and each raises a `TypeError` naming its replacement rather than being silently ignored. - -`name`, `version`, `instructions`, `icons`, `website_url`, `lifespan`, `resource_security`, and `request_state_security` all mean what they meant before. The rest map like this: - -| `MCPServer(...)` | FastMCP | -|---|---| -| `debug=True` | `FASTMCP_DEBUG` environment variable | -| `log_level="DEBUG"` | `run_http_async(log_level=...)` or `FASTMCP_LOG_LEVEL` | -| `warn_on_duplicate_tools`, `_resources`, `_prompts` | a single `on_duplicate=` | -| `dependencies=[...]` | a [`fastmcp.json`](/deployment/server-configuration) configuration file | -| `title=`, `description=` | `instructions=` | -| `tools=[Tool, ...]` | `tools=[callable, ...]`, or FastMCP's own `Tool` | -| `resources=[Resource, ...]` | no constructor keyword — register with `@mcp.resource` or `mcp.add_resource()` | -| `subscriptions=<SubscriptionBus>` | no equivalent — see below | -| `token_verifier=`, `auth_server_provider=`, `auth=AuthSettings(...)` | a single `auth=` provider | -| `cache_hints={...}` | `cache_ttl=`, `cache_scope=` | -| `extensions=[...]` | `mcp.add_extension(...)` | -| `middleware=[ServerMiddleware, ...]` | `middleware=[Middleware, ...]` — same keyword, different class | - -`middleware=` is the row most likely to be mistaken for a rename. Both constructors take a `middleware=` sequence, but an `MCPServer` wants the SDK's `ServerMiddleware` — one hook wrapping every raw JSON-RPC message — while FastMCP wants its own `Middleware`, which adds typed per-operation hooks (`on_call_tool`, `on_list_tools`, and the rest) on top of the same message-level pass. Keeping the keyword and swapping the base class is the migration; see [Middleware](/servers/middleware). - -Authentication is the largest of these, and it consolidates rather than moves. `MCPServer` exposes the SDK's raw auth plumbing — a token verifier, an authorization-server provider, and an `AuthSettings` object, configured separately. FastMCP takes one `auth=` provider that carries the whole configuration, and ships providers for the common cases: `JWTVerifier` for validating tokens you already issue, `RemoteAuthProvider` for delegating to an external authorization server, `OAuthProxy` for wrapping a provider that lacks Dynamic Client Registration, and named providers for GitHub, Google, Auth0, Keycloak, WorkOS, and others. See [Authentication](/servers/auth/authentication). - -Two rows are worth reading before you delete the argument. `resources=` has no constructor equivalent, so pre-built `Resource` objects need registering through `@mcp.resource` or `mcp.add_resource()` instead — dropping the keyword silently drops the resources with it. And `subscriptions=`, which an `MCPServer` uses to plug in an external pub/sub bus so resource-update notifications reach clients across replicas, has no FastMCP equivalent at all. A multi-replica deployment that relies on it should confirm it can live without cross-replica subscription fan-out before migrating, because a mechanical rename removes that behavior without any error to warn you. - -### Serving HTTP - -Renaming `streamable_http_app()` to `http_app()` is only mechanical for a call with no arguments. The keywords were renamed and regrouped, so an existing call carries arguments `http_app()` does not accept: - -| SDK v2 | FastMCP | -|---|---| -| `streamable_http_app(streamable_http_path=...)` | `http_app(path=...)` | -| `sse_app(sse_path=...)` | `http_app(path=..., transport="sse")` | -| `sse_app(message_path=...)` | no equivalent | -| `transport_security=TransportSecuritySettings(...)` | `host_origin_protection=`, `allowed_hosts=`, `allowed_origins=` | -| `host=...` | pass to `mcp.run(host=...)` instead | - -`json_response`, `stateless_http`, `event_store`, and `retry_interval` keep their names. See [Deploying HTTP servers](/deployment/http) for the host and origin settings. - -### Stricter Arguments - -One behavior change survives the rename and is worth knowing before you migrate. `MCPServer` binds the arguments it recognizes and ignores the rest, so a call carrying an unexpected key succeeds. FastMCP declares `"additionalProperties": false` on every generated schema and enforces it, so the same call fails: - -```python test="skip" -# Against MCPServer: succeeds, "extra" ignored. -# Against FastMCP: raises, "extra" is not a parameter of greet(). -await client.call_tool("greet", {"name": "World", "extra": "surprise"}) -``` - -For most servers this is an improvement that costs nothing — a caller sending keys your tool never reads was already a bug. It matters if a client in your fleet passes extra metadata alongside real arguments, since those calls start failing the moment you migrate. Accept the extras explicitly as optional parameters if you need to keep them working. - -## Asking for Input - -This is the one part of the migration that is not a rename, so read it before you start if your tools use resolvers. - -`MCPServer` asks the client for things through dependency-injection resolvers. A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body, and the resolver can return a request marker — `Elicit[T]` to ask the user, `Sample` to borrow the client's model, `ListRoots` to fetch its roots — which the framework turns into the right wire interaction for whichever protocol era the connection negotiated: - -```python -from typing import Annotated -from pydantic import BaseModel -from mcp.server.mcpserver import MCPServer, Resolve, Elicit - -server = MCPServer("booking") - - -class Destination(BaseModel): - destination: str - - -def ask_destination() -> Elicit[Destination]: - return Elicit("Where would you like to fly?", Destination) - - -@server.tool() -def book_flight(dest: Annotated[Destination, Resolve(ask_destination)]) -> str: - """Book a flight""" - return f"Booked to {dest.destination}" -``` - -FastMCP has no equivalent annotation, and it makes the protocol era explicit instead of hiding it. Which replacement you want depends on which era your clients speak. - -On **handshake-era connections** (≤ 2025-11-25), a running tool asks the user directly with `ctx.elicit()`, and the call blocks until the answer arrives. Where the resolver returned a value or aborted the call, `ctx.elicit()` hands you the outcome to branch on, so declining and cancelling become cases your tool answers for itself: - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP("booking") - - -@mcp.tool -async def book_flight(ctx: Context) -> str: - """Book a flight""" - result = await ctx.elicit("Where would you like to fly?", response_type=str) - if result.action == "accept": - return f"Booked to {result.data}" - return "Booking cancelled" -``` - -On the **modern protocol** (2026-07-28), server-initiated requests are gone from the wire, so a tool asks by *returning* a description of what it needs. The client answers and calls the tool again with the answer attached, and the tool re-runs from the top. This is the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), and it reads the answers off `ctx.input_responses`. - -The two are era-gated in both directions: `ctx.elicit()` raises on a modern connection, and a guard result raises on a handshake one. A server that must serve both branches on `ctx.request_context.protocol_version`. See [Elicitation](/servers/elicitation#which-approach-to-use) for both shapes side by side. - -Resolvers that return `Sample` or `ListRoots` have no *injected* equivalent — FastMCP has no `ctx.sample()` or `ctx.list_roots()` — but the underlying request survives, so this is a change of shape rather than a loss of capability. On a modern connection both ride the same guard pattern as elicitation: the tool returns an `InputRequiredResult` describing the sampling or roots request, and the client answers on the next call. - -Which shape you want differs by capability. For **roots**, the guard route is the natural replacement, since one round buys the whole answer — and taking the paths as ordinary tool arguments is simpler still whenever the caller can supply them. For **generation**, prefer [calling an LLM from your server](/servers/sampling) with your own API key: your tool then behaves identically for every client, including the many that never implemented sampling, and you avoid paying a full request-response cycle per generation step. Reach for the guard route when using the *caller's* model is specifically the point. - -One schema detail is easy to miss during the rewrite. A resolved parameter never appears in the tool's input schema — `book_flight` above advertises no arguments at all. When you replace a resolver with an explicit tool argument, the schema the client sees gains a field, which is usually what you want but is a visible change to your tool's contract. - -## What You Gain - -The migration is worth doing for what sits on the other side of it. FastMCP is a framework rather than a protocol surface, and these are the capabilities that most often motivate the move: - -[Server composition](/servers/composition) mounts one server inside another, so a large surface splits into modules that are developed and tested independently. [Middleware](/servers/middleware) runs across every request for logging, rate limiting, error handling, and caching, with hooks at whichever level of specificity you need. [Proxy servers](/servers/providers/proxy) put a FastMCP server in front of any existing MCP server, bridging transports and adding auth to a backend you don't control. The [OpenAPI integration](/integrations/openapi) generates a whole server from an existing API specification. [Tool transformation](/servers/transforms/transforms) rewrites the tools a server exposes — renaming, hiding, and reshaping arguments — without touching the code that defines them. - -FastMCP also ships a [client](/clients/client), which `MCPServer` has no counterpart for. It speaks every transport, drives both protocol eras, and connects to a server object in-process — so [testing](/servers/testing) a server means calling its tools in the same Python process, with no subprocess and no network. diff --git a/docs/v3/getting-started/upgrading/from-mcp-sdk.mdx b/docs/getting-started/upgrading/from-mcp-sdk.mdx similarity index 81% rename from docs/v3/getting-started/upgrading/from-mcp-sdk.mdx rename to docs/getting-started/upgrading/from-mcp-sdk.mdx index 494d06bdc..919df361a 100644 --- a/docs/v3/getting-started/upgrading/from-mcp-sdk.mdx +++ b/docs/getting-started/upgrading/from-mcp-sdk.mdx @@ -32,7 +32,7 @@ uv add fastmcp FastMCP includes the `mcp` package as a dependency, so you don't lose access to anything. Update your import, run your server, and if your tools work, you're done. <Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance."> -You are upgrading an MCP server from FastMCP 1.0 (bundled in the `mcp` package v1) to standalone FastMCP 3.0. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. +You are upgrading an MCP server from FastMCP 1.0 (bundled in the `mcp` package v1) to standalone FastMCP 4. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. STEP 1 — IMPORT (required for all servers): Change "from mcp.server.fastmcp import FastMCP" to "from fastmcp import FastMCP". @@ -51,9 +51,9 @@ Also: if prompts return raw dicts like `{"role": "user", "content": "..."}`, the The MCP SDK's FastMCP 1.0 silently coerced dicts; standalone FastMCP requires typed returns. STEP 4 — OTHER MCP IMPORTS (only if importing from mcp.* directly): -Direct imports from the `mcp` package (e.g., `import mcp.types`, `from mcp.server.stdio import stdio_server`) still work because FastMCP includes `mcp` as a dependency. However, prefer FastMCP's own APIs where equivalents exist: -- mcp.types.TextContent for tool returns → just return plain Python values (str, int, dict, etc.) -- mcp.types.ImageContent → fastmcp.utilities.types.Image +FastMCP now builds on MCP SDK v2, which removed the `mcp.types` module — protocol types live in the standalone `mcp_types` package. FastMCP re-exports the common ones from `fastmcp.types`. Update any `from mcp.types import X` to `from fastmcp.types import X` (or `import mcp_types`). Prefer FastMCP's own APIs where equivalents exist: +- fastmcp.types.TextContent for tool returns → just return plain Python values (str, int, dict, etc.) +- fastmcp.types.ImageContent → fastmcp.utilities.types.Image - from mcp.server.stdio import stdio_server → not needed, mcp.run() handles transport STEP 5 — DECORATORS (only if treating decorated functions as objects): @@ -113,7 +113,7 @@ def debug(error: str) -> list[Message]: ### Other `mcp.*` Imports -If your server imports directly from the `mcp` package — like `import mcp.types` or `from mcp.server.stdio import stdio_server` — those still work. FastMCP includes `mcp` as a dependency, so nothing breaks. +FastMCP now builds on MCP SDK v2. The `mcp.types` module no longer exists — protocol types moved to a standalone `mcp_types` package, and the field names were renamed from camelCase to snake_case (`inputSchema` → `input_schema`, `mimeType` → `mime_type`, and so on). FastMCP re-exports the types you're most likely to use from `fastmcp.types`, so update `from mcp.types import X` to `from fastmcp.types import X`. For the full picture, see [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3). Where FastMCP provides its own API for the same thing, it's worth switching over: @@ -124,7 +124,7 @@ Where FastMCP provides its own API for the same thing, it's worth switching over | `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` | | `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport | -For anything without a FastMCP equivalent (e.g., specific protocol types you use directly), the `mcp.*` import is fine to keep. +For protocol types without a FastMCP equivalent, import them from `fastmcp.types` when re-exported there, otherwise from `mcp_types` directly. ### Decorated Functions diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx index 5c13e6bdf..d42dc39b7 100644 --- a/docs/getting-started/welcome.mdx +++ b/docs/getting-started/welcome.mdx @@ -1,11 +1,26 @@ --- -title: "FastMCP: The Framework for MCP" +title: "Welcome to FastMCP" sidebarTitle: "Welcome!" -description: FastMCP is the standard framework for building Model Context Protocol (MCP) servers, clients, and interactive applications. +description: The fast, Pythonic way to build MCP servers, clients, and applications. icon: hand-wave mode: center --- +{/* <img + src="/assets/brand/f-watercolor-waves-4.png" + alt="'F' logo on a watercolor background" + noZoom + className="rounded-2xl block dark:hidden" + /> + <img + src="/assets/brand/f-watercolor-waves-4-dark.png" + alt="'F' logo on a watercolor background" + noZoom + className="rounded-2xl hidden dark:block" + /> + + + */} <video autoPlay muted @@ -23,112 +38,97 @@ mode: center src="/assets/brand/f-watercolor-waves-4-dark-animated.mp4" ></video> -**FastMCP is a full framework for building [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) applications.** It gives you one coherent API for servers, clients, and interactive apps. Use it to expose Python functions as MCP tools, connect to local or remote MCP servers, and return interactive interfaces directly from your tools. FastMCP manages schema generation, validation, transport, authentication, and protocol compatibility around your application code. -A FastMCP server starts with ordinary Python: +**FastMCP is the standard framework for building MCP applications.** The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production — build servers that expose capabilities, connect clients to any MCP service, and give your tools interactive UIs: ```python {1} from fastmcp import FastMCP mcp = FastMCP("Demo 🚀") - @mcp.tool def add(a: int, b: int) -> int: - """Add two numbers.""" + """Add two numbers""" return a + b - if __name__ == "__main__": mcp.run() ``` -## Move fast and make things -An effective MCP application needs more than a function registry. Models need accurate schemas, callers need validated results, clients need compatible transports, and production servers need authentication and predictable lifecycle management. +## Move Fast and Make Things -FastMCP treats those as framework responsibilities. Declare a Python function and FastMCP derives its schema, validates its inputs and outputs, and exposes it through MCP. Connect a client to a URL and FastMCP handles protocol negotiation, authentication, and connection lifecycle. Your application remains ordinary Python while FastMCP keeps the MCP boundary correct. +The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) lets you give agents access to your tools and data. But building an effective MCP application is harder than it looks. -**That's why FastMCP is the standard framework for working with MCP.** FastMCP created the high-level Python API incorporated into the official MCP Python SDK in 2024. The actively maintained standalone project is now downloaded more than a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages. +FastMCP handles all of it. Declare a tool with a Python function, and the schema, validation, and documentation are generated automatically. Connect to a server with a URL, and transport negotiation, authentication, and protocol lifecycle are managed for you. You focus on your logic, and the MCP part just works: **with FastMCP, best practices are built in.** -## Servers, clients, and apps +**That's why FastMCP is the standard framework for working with MCP.** FastMCP 1.0 was incorporated into the official MCP Python SDK in 2024. Today, the actively maintained standalone project is downloaded a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages. -FastMCP covers the full MCP application lifecycle through three complementary pillars: +FastMCP has three pillars: <CardGroup cols={3}> <Card title="Servers" img="/assets/images/servers-card.png" href="/servers/server"> - Expose Python functions, data, and instructions as MCP tools, resources, and prompts. + Expose tools, resources, and prompts to LLMs. </Card> <Card title="Apps" img="/assets/images/apps-card.png" href="/apps/overview"> - Give MCP tools interactive user interfaces rendered directly in the conversation. + Give your tools interactive UIs rendered directly in the conversation. </Card> <Card title="Clients" img="/assets/images/clients-card.png" href="/clients/client"> - Connect to any MCP server through Python, the command line, or another MCP application. + Connect to any MCP server — local or remote, programmatic or CLI. </Card> </CardGroup> -**[Servers](/servers/server)** turn your application logic into MCP capabilities with generated schemas and validation. **[Clients](/clients/client)** connect to local or remote MCP servers with full protocol support. **[Apps](/apps/overview)** let tools return forms, tables, charts, and other interactive interfaces alongside ordinary MCP results. +**[Servers](/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](/clients/client)** connect to any server with full protocol support. And **[Apps](/apps/overview)** give your tools interactive UIs rendered directly in the conversation. -The three pillars share one model: FastMCP owns the protocol machinery while your code defines what the application does. - -**Building in TypeScript?** [FastMCP for TypeScript](https://github.com/PrefectHQ/fastmcp-ts) is the official counterpart, built and maintained by the same team. Its servers, clients, and apps follow the same concepts, so what you learn here carries over. - -<CardGroup cols={2}> - <Card title="Install FastMCP" icon="download" href="/getting-started/installation"> - Add FastMCP to your project with `uv add fastmcp`, verify the package, and find the right upgrade guide. - </Card> - <Card title="Build your first server" icon="rocket-launch" href="/getting-started/quickstart"> - Create a tool, run its server, call it from a client, and add an interactive UI. - </Card> -</CardGroup> +Ready to build? Start with the [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart). FastMCP is made with 💙 by [Prefect](https://www.prefect.io/). -<Tip> -**This documentation reflects FastMCP's `main` branch**, so it may describe features that have not reached a stable release. Version badges identify when features were introduced. -</Tip> +## Run FastMCP in production with Horizon -## Scale MCP with Horizon +FastMCP is the standard way to build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_body)** is the enterprise MCP gateway for running them safely. -FastMCP handles the MCP application layer. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_body)** is the enterprise MCP gateway for scaling servers and tools across teams, with centralized governance over how they are deployed, discovered, secured, and used. +Built by the FastMCP team, Horizon packages the best practices we've learned shipping the world's most popular MCP framework. -Horizon applies the operational patterns developed while maintaining FastMCP: deploy servers from GitHub with branch previews and instant rollback, organize them in a private registry, protect access with SSO and tool-level RBAC, and observe activity through audit logs and telemetry. - -Horizon can also combine approved tools into purpose-built MCP endpoints for different teams and agents, while keeping access policy and governance centralized. +Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents. Start with FastMCP. [Scale with Horizon →](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_cta) -## LLM-friendly docs +<Tip> +**This documentation reflects FastMCP's `main` branch**, meaning it always reflects the latest development version. Features are generally marked with version badges (e.g. `New in version: 3.0.0`) to indicate when they were introduced. Note that this may include features that are not yet released. +</Tip> -FastMCP documentation is designed for developers and coding agents. Every page is available as Markdown, the complete documentation is published in `llms.txt` formats, and the documentation itself is exposed through an MCP server. +## LLM-Friendly Docs -### MCP server +The FastMCP documentation is available in multiple LLM-friendly formats: -Point any MCP-compatible agent at `https://gofastmcp.com/mcp` to let it search the documentation as it works. You can also connect with FastMCP's Python client directly: +### MCP Server + +The FastMCP docs are accessible via MCP! The server URL is `https://gofastmcp.com/mcp`. + +In fact, you can use FastMCP to search the FastMCP docs: ```python import asyncio - from fastmcp import Client - -async def main() -> None: +async def main(): async with Client("https://gofastmcp.com/mcp") as client: result = await client.call_tool( name="search_fast_mcp", - arguments={"query": "deploy a FastMCP server"}, + arguments={"query": "deploy a FastMCP server"} ) - print(result) - + print(result) asyncio.run(main()) ``` -### Markdown formats +### Text Formats -The documentation is also available in [`llms.txt`](https://llmstxt.org/) formats: +The docs are also available in [llms.txt format](https://llmstxt.org/): +- [llms.txt](https://gofastmcp.com/llms.txt) - A sitemap listing all documentation pages +- [llms-full.txt](https://gofastmcp.com/llms-full.txt) - The entire documentation in one file (may exceed context windows) -- [`llms.txt`](https://gofastmcp.com/llms.txt) lists every documentation page. -- [`llms-full.txt`](https://gofastmcp.com/llms-full.txt) contains the complete documentation in one file and may exceed some context windows. +Any page can be accessed as markdown by appending `.md` to the URL. For example, this page becomes `https://gofastmcp.com/getting-started/welcome.md`. -Append `.md` to any documentation URL to retrieve that page as Markdown. For example, this page is available at `https://gofastmcp.com/getting-started/welcome.md`. You can also copy the current page as Markdown by pressing `Cmd+C` or `Ctrl+C`. +You can also copy any page as markdown by pressing "Cmd+C" (or "Ctrl+C" on Windows) on your keyboard. diff --git a/docs/getting-started/whats-new.mdx b/docs/getting-started/whats-new.mdx deleted file mode 100644 index cebc3e682..000000000 --- a/docs/getting-started/whats-new.mdx +++ /dev/null @@ -1,236 +0,0 @@ ---- -title: "What's New in FastMCP 4" -sidebarTitle: "What's New" -description: FastMCP 4 makes stateful MCP applications work on the sessionless protocol while one server serves every protocol era. -icon: sparkles ---- - -FastMCP 4 makes stateful MCP applications work on MCP's sessionless protocol. Tools can ask follow-up questions across requests, preserve authenticated user state, and move long-running work into background tasks without sticky sessions or a continuously connected client. - -The protocol changed completely underneath those APIs. Your application usually does not: one FastMCP server negotiates both protocol eras per connection, and most FastMCP 3 servers upgrade unchanged. - -That is the theme of version 4: stateless transport without stateless application code. The release also makes protocol extensions a first-class surface, adds enterprise identity for agents acting on behalf of users, and strengthens production defaults across caching, routing, and security. - -<Note> -FastMCP 4 is in **beta**. Pin an exact version and expect sharp edges. See [Install the v4 prerelease](/getting-started/upgrading/from-fastmcp-3#install-the-v4-prerelease). -</Note> - -## Protocol compatibility - -A protocol migration usually forces a choice between breaking clients that have not moved yet and holding the server back with them. FastMCP 4 serves both eras from one deployment, negotiating the best mutual version for each connection. Modern clients get the sessionless protocol while handshake-era clients continue working unchanged. - -Statelessness changes how that deployment scales. Each modern request carries everything needed to answer it, so any replica behind an ordinary load balancer can serve any request and session affinity stops being a requirement. - -The client default follows the same rule. `Client(url)` probes for the modern protocol and falls back to the handshake when necessary. Pin `mode="legacy"` only when your application specifically needs the session back-channel. - -```python -from fastmcp import Client - -# Negotiate the best mutual protocol -client = Client("https://example.com/mcp") - -# Require the handshake-era protocol -legacy = Client("https://example.com/mcp", mode="legacy") -``` - -Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` expose the same interface whichever era was negotiated. Application code that inspects a server does not need a protocol-version branch. See [Protocol negotiation](/clients/client#protocol-negotiation). - -On modern connections, FastMCP also attaches the method, target name, and opted-in argument values as HTTP headers. Gateways and load balancers can route requests without parsing JSON-RPC bodies. See [Gateway routing headers](/deployment/http#gateway-routing-headers). - -## Stateful applications - -The modern protocol removes transport-level sessions, but applications still need conversations, user state, and long-running work. FastMCP moves those concerns into explicit application primitives that survive fresh connections. Shared stores and request-state keys extend them across replicas and worker restarts. - -### Interactive tools - -Many useful tools need more than one exchange. A booking tool asks for a destination, then a date, then confirmation. A destructive operation asks the user to approve it before continuing. - -On the modern protocol, the tool returns a description of the input it needs. That result completes the request normally. The client fulfils the request and calls the tool again with the answer attached; the tool runs from the top, reads `ctx.input_responses`, and either asks another question or returns its final result. - -Each request completes while the user responds. Single-process servers use an automatic process-local key to protect the state carried between rounds; load-balanced deployments configure one shared key so any replica can validate and resume the next round: - -```python -import os - -from fastmcp import Context, FastMCP -from mcp.server.request_state import RequestStateSecurity -from mcp.types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult - -mcp = FastMCP( - "Booking", - request_state_security=RequestStateSecurity( - keys=[os.environ["REQUEST_STATE_KEY"].encode()] - ), -) - - -@mcp.tool -async def book_flight(ctx: Context) -> str | InputRequiredResult: - answers = ctx.input_responses - if answers is None: - params = ElicitRequestFormParams( - message="Where would you like to fly?", - requested_schema={ - "type": "object", - "properties": {"destination": {"type": "string"}}, - "required": ["destination"], - }, - ) - return InputRequiredResult( - result_type="input_required", - input_requests={ - "destination": ElicitRequest( - method="elicitation/create", - params=params, - ) - }, - ) - - response = answers["destination"] - if response.action != "accept" or response.content is None: - return "Booking cancelled." - - destination = response.content["destination"] - return f"Booked a flight to {destination}." -``` - -Every replica must receive the same `REQUEST_STATE_KEY`, containing at least 32 bytes of secret key material. A FastMCP client drives the loop through its existing elicitation handler, so client code receives the terminal result without managing the intermediate rounds. See [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol). - -### Session state - -Application state follows the same explicit model. FastMCP stores state server-side and binds it to the authenticated user, so a session handle is inert in another user's hands. - -Most tools want one state bucket per user. Declare a `UserSession` parameter and FastMCP injects it like `Context`: it never appears in the tool schema, and the caller passes nothing because their authenticated identity selects the bucket. - -```python -from fastmcp import FastMCP -from fastmcp.server.sessions import UserSession - -mcp = FastMCP("Assistant") - - -@mcp.tool -async def remember(fact: str, session: UserSession) -> str: - facts = await session.get("facts", default=[]) - facts.append(fact) - await session.set("facts", facts) - return f"Remembered {len(facts)} facts." -``` - -`UserSession` requires [authentication](/servers/auth/authentication), since an unauthenticated request has no user to key on. When one user needs several independent buckets, such as separate carts or conversations, `SessionId` exposes the handle as an explicit string argument. - -The default in-memory state store is process-local. To preserve state across restarts or share it among replicas, pass a shared persistent `session_state_store`. See [Session state](/servers/sessions). - -### Background work - -Long-running tools create a different kind of state problem: holding a request open for several minutes invites timeouts and leaves the user unable to tell whether work is progressing. Background tasks accept the call and return a handle immediately, then let the client poll while work proceeds asynchronously. - -FastMCP implements the `io.modelcontextprotocol/tasks` extension in the optional `fastmcp-tasks` package. The authoring API remains `@mcp.tool(task=True)`, backed by [Docket](https://github.com/chrisguidry/docket): - -```python -import asyncio - -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension - -mcp = FastMCP("MyServer") -mcp.add_extension(TasksExtension()) - - -@mcp.tool(task=True) -async def slow_computation(duration: int) -> str: - """Run a long computation.""" - await asyncio.sleep(duration) - return f"Completed in {duration} seconds" -``` - -`fastmcp.Client` handles the task handle and polling cycle, so `client.call_tool(...)` returns the same way whether the tool ran inline or in the background. See [Background tasks](/servers/tasks). - -`TasksExtension()` uses an in-memory, single-process backend by default. Configure a Redis or Valkey backend for durable work that survives restarts and runs across separate workers. - -## Extensible protocol - -Background tasks are built on a general extension surface. An MCP extension advertises a capability under a reverse-DNS identifier and can add behavior negotiated between a server and client. - -### Server extensions - -`FastMCP.add_extension()` lets an extension advertise capabilities, add request methods, intercept `tools/call`, and own lifespan behavior with access to the component registry, `Context`, and authentication. Client extensions use the matching `Client(extensions=...)` interface. - -Cross-cutting protocol behavior can therefore live in a supported plugin instead of requiring changes to FastMCP core. `TasksExtension` is a complete example of the interface. See [Server extensions](/servers/extensions). - -### Argument completion - -FastMCP 4 also lets servers answer MCP argument-completion requests. A completion handler sees the prompt or resource-template argument, its partial value, and values already supplied, so suggestions can depend on earlier choices. - -```python -from fastmcp import FastMCP -from mcp.types import PromptReference - -mcp = FastMCP("Docs") - - -@mcp.prompt -def write_poem(theme: str) -> str: - return f"Write a poem about {theme}" - - -@mcp.completion -def complete(ref, argument, context): - if isinstance(ref, PromptReference) and argument.name == "theme": - options = ["nature", "love", "adventure"] - return [option for option in options if option.startswith(argument.value)] - return None -``` - -Registering the handler advertises the completion capability during negotiation, so clients only send requests to servers that support them. See [Argument completion](/servers/completions). - -## Enterprise identity - -Interactive OAuth authorization assumes a person can complete a browser flow. Internal agents often act for employees without a person waiting at a keyboard, while the server still needs the employee's identity for authorization and audit. - -Identity assertion carries that identity through the agent. A corporate identity provider signs an assertion, the agent presents it, and the server exchanges it for a short-lived token without an interactive login or consent screen. FastMCP performs signature verification, binding checks, replay rejection, and scoped token issuance through the authentication providers you already use. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import IdentityAssertion, OAuthProxy - -auth = OAuthProxy( - # Existing upstream configuration - identity_assertion=IdentityAssertion( - trusted_issuers=["https://login.acme-corp.com"] - ), -) -mcp = FastMCP("Internal API", auth=auth) -``` - -The asserted subject enters the normal authentication context, so tools read it through `get_access_token()` like any other identity. See [Identity assertion](/servers/auth/oauth-proxy#identity-assertion-sep-990). - -Authorization gained a provider-neutral role check as well. `require_roles` accepts an extraction function for providers that store roles and groups under different claims, while [scope step-up challenges](/servers/authorization#signaling-scope-shortfalls) tell a client exactly which scopes to request. - -For clients with no user behind them, such as backend services and scheduled jobs, `ClientCredentialsOAuthProvider` implements the OAuth 2.0 client-credentials grant with no browser or redirect. See [Machine-to-machine authentication](/clients/auth/client-credentials). - -## Production defaults - -A server can now attach freshness hints to its results, and a caching client can reuse those results without another round trip. Set a default time-to-live and scope on the server: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("Weather", cache_ttl=300, cache_scope="public") -``` - -`KeyValueResponseCacheStore` can place the client cache in Redis or another key-value store so a fleet of clients or proxies shares fills. See [Response caching](/clients/client#response-caching). - -Resource templates now reject path traversal, absolute paths, and null bytes in their parameters before the handler runs. The protection is enabled by default and applies to mounted and proxied templates. See [Path security](/servers/resources#path-security). - -OAuth defaults also distinguish native clients from web applications during Dynamic Client Registration, and missing scopes now produce an `InsufficientScopeError` that names the scopes required to continue. See [Application type](/servers/auth/oauth-proxy#application-type-web-vs-native) and [scope shortfalls](/servers/authorization#signaling-scope-shortfalls). - -## Upgrade note - -The sessionless protocol has no live connection for a server to call back into during execution. FastMCP 4 therefore removes `ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` from every protocol era so incompatible code fails immediately during an upgrade. - -For generation, call an LLM directly from the server when your application owns the model. When borrowing the caller's model is the point, return an `InputRequiredResult` carrying a sampling request and read the answer on the next round. Roots use the same return-and-resume pattern. See [Sampling](/servers/sampling) and [the guard pattern](/servers/elicitation#sampling-and-roots). - -`ctx.elicit()` remains available on handshake-era connections; modern connections use the multi-round pattern described above. Code that constructs MCP protocol models directly must also use snake_case Python field names with SDK v2. - -[Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers these changes and every other compatibility break. diff --git a/docs/integrations/anthropic.mdx b/docs/integrations/anthropic.mdx index 6fb8841e1..08b9b2c9c 100644 --- a/docs/integrations/anthropic.mdx +++ b/docs/integrations/anthropic.mdx @@ -69,11 +69,9 @@ You'll also need to authenticate with Anthropic. You can do this by setting the export ANTHROPIC_API_KEY="your-api-key" ``` -Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. +Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. **At this time you must also include the `extra_headers` parameter with the `anthropic-beta` header.** -The connector is in beta, so the call goes through `client.beta.messages` with the `mcp-client-2025-11-20` flag. Each entry in `mcp_servers` also needs a matching `mcp_toolset` entry in `tools` that references it by name; declaring the server without the toolset is rejected as a validation error. - -```python {5, 14-23} +```python {5, 13-22} import anthropic from rich import print @@ -83,9 +81,8 @@ url = 'https://your-server-url.com' client = anthropic.Anthropic() response = client.beta.messages.create( - model="claude-sonnet-5", + model="claude-sonnet-4-20250514", max_tokens=1000, - betas=["mcp-client-2025-11-20"], messages=[{"role": "user", "content": "Roll a few dice!"}], mcp_servers=[ { @@ -94,7 +91,9 @@ response = client.beta.messages.create( "name": "dice-server", } ], - tools=[{"type": "mcp_toolset", "mcp_server_name": "dice-server"}], + extra_headers={ + "anthropic-beta": "mcp-client-2025-04-04" + } ) print(response.content) @@ -194,7 +193,7 @@ Error code: 400 - { To authenticate the client, you can pass the token using the `authorization_token` parameter in your MCP server configuration: -```python {8, 22} +```python {8, 21} import anthropic from rich import print @@ -207,9 +206,8 @@ access_token = 'your-access-token' client = anthropic.Anthropic() response = client.beta.messages.create( - model="claude-sonnet-5", + model="claude-sonnet-4-20250514", max_tokens=1000, - betas=["mcp-client-2025-11-20"], messages=[{"role": "user", "content": "Roll a few dice!"}], mcp_servers=[ { @@ -219,7 +217,9 @@ response = client.beta.messages.create( "authorization_token": access_token } ], - tools=[{"type": "mcp_toolset", "mcp_server_name": "dice-server"}], + extra_headers={ + "anthropic-beta": "mcp-client-2025-04-04" + } ) print(response.content) diff --git a/docs/integrations/auth0.mdx b/docs/integrations/auth0.mdx index ce2dcc670..65f9d3873 100644 --- a/docs/integrations/auth0.mdx +++ b/docs/integrations/auth0.mdx @@ -9,54 +9,9 @@ import { VersionBadge } from "/snippets/version-badge.mdx" <VersionBadge version="2.12.4" /> -FastMCP supports two Auth0 integration paths: +This guide shows you how to secure your FastMCP server using **Auth0 OAuth**. While Auth0 does have support for Dynamic Client Registration, it is not enabled by default so this integration uses the [**OIDC Proxy**](/servers/auth/oidc-proxy) pattern to bridge Auth0's dynamic OIDC configuration with MCP's authentication requirements. -- **[Auth for MCP](#auth-for-mcp-dcr)** — Auth0 handles OAuth, DCR, and CIMD; FastMCP validates tokens (`Auth0MCPProvider`). Use this for MCP-native clients and Auth0's [Auth for MCP](https://auth0.com/ai/docs/mcp/intro/overview) setup. -- **[OIDC Proxy](#oidc-proxy-fixed-credentials)** — FastMCP proxies OAuth with fixed application credentials (`Auth0Provider`). Use this when you manage an Auth0 application manually and do not need tenant-level DCR. - -## Auth for MCP (DCR) - -<VersionBadge version="4.0.0" /> - -This path uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern. Auth0 acts as the authorization server; FastMCP is the resource server. - -### Prerequisites - -1. An **[Auth0 account](https://auth0.com/)** with **Auth for MCP** enabled -2. **Resource Parameter Compatibility Profile** enabled (Settings → Advanced) -3. Your FastMCP server URL (use `http://127.0.0.1:8000` in development — not `localhost`) - -See Auth0's [authorization quickstart](https://auth0.com/ai/docs/mcp/get-started/authorization-for-your-mcp-server) for tenant setup (API identifier, domain-level connections, CIMD approval). - -### Step 1: Create an Auth0 API - -Create an API (Resource Server) whose **identifier** is your MCP resource URL, for example `http://127.0.0.1:8000/mcp`. Use `RS256` signing and the `rfc9068_profile_authz` token dialect if you need `permissions` claims on tokens. - -When the server starts, it logs the exact `aud` value it validates — your API identifier must match. - -### Step 2: FastMCP configuration - -```python server_mcp.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0MCPProvider - -auth_provider = Auth0MCPProvider( - config_url="https://YOUR_TENANT.auth0.com/.well-known/openid-configuration", - base_url="http://127.0.0.1:8000", -) - -mcp = FastMCP(name="Auth0 MCP Server", auth=auth_provider) -``` - -No `client_id` or `client_secret` is required on the FastMCP side — MCP clients register with Auth0 directly. - -### Testing - -See `examples/auth/auth0_mcp/` for a runnable server and DCR client. Set `AUTH0_CONFIG_URL` to your tenant's OIDC discovery URL before starting the server. - -## OIDC Proxy (fixed credentials) - -This integration uses the [**OIDC Proxy**](/servers/auth/oidc-proxy) pattern when you use a fixed Auth0 application instead of tenant-level DCR. +## Configuration ### Prerequisites @@ -182,8 +137,7 @@ async def main(): # Test the protected tool result = await client.call_tool("get_token_info") - token_info = result.data - print(f"Auth0 audience: {token_info['audience']}") + print(f"Auth0 audience: {result['audience']}") if __name__ == "__main__": asyncio.run(main()) diff --git a/docs/integrations/authkit.mdx b/docs/integrations/authkit.mdx index 05c6e5d7b..c77175201 100644 --- a/docs/integrations/authkit.mdx +++ b/docs/integrations/authkit.mdx @@ -81,8 +81,7 @@ auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"}) async def main(): async with Client("http://127.0.0.1:8000/mcp", auth=auth) as client: - tools = await client.list_tools() - print(f"Authenticated. Server exposes {len(tools)} tools.") + assert await client.ping() if __name__ == "__main__": asyncio.run(main()) diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index c683fbce4..ddcef6a2a 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -223,9 +223,8 @@ async def main(): # Test the protected tool result = await client.call_tool("get_user_info") - user_info = result.data - print(f"Azure user: {user_info['email']}") - print(f"Name: {user_info['name']}") + print(f"Azure user: {result['email']}") + print(f"Name: {result['name']}") if __name__ == "__main__": asyncio.run(main()) diff --git a/docs/integrations/chatgpt.mdx b/docs/integrations/chatgpt.mdx index 23249f92c..e1fb663e2 100644 --- a/docs/integrations/chatgpt.mdx +++ b/docs/integrations/chatgpt.mdx @@ -95,7 +95,7 @@ The connector must be explicitly enabled in each chat session through Developer Use `annotations=ToolAnnotations(readOnlyHint=True)` to skip confirmation prompts for read-only tools: ```python -from mcp.types import ToolAnnotations +from fastmcp.types import ToolAnnotations @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) def get_status() -> str: diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx index 7addeaaad..8098ff51e 100644 --- a/docs/integrations/claude-code.mdx +++ b/docs/integrations/claude-code.mdx @@ -118,7 +118,7 @@ fastmcp install claude-code server.py --project /path/to/my-project If your server needs environment variables (like API keys), you must include them: ```bash -fastmcp install claude-code server.py --name "Weather Server" \ +fastmcp install claude-code server.py --server-name "Weather Server" \ --env API_KEY=your-api-key \ --env DEBUG=true ``` @@ -126,7 +126,7 @@ fastmcp install claude-code server.py --name "Weather Server" \ Or load them from a `.env` file: ```bash -fastmcp install claude-code server.py --name "Weather Server" --env-file .env +fastmcp install claude-code server.py --server-name "Weather Server" --env-file .env ``` <Warning> diff --git a/docs/integrations/claude-desktop.mdx b/docs/integrations/claude-desktop.mdx index b0d1b5265..4478bcc37 100644 --- a/docs/integrations/claude-desktop.mdx +++ b/docs/integrations/claude-desktop.mdx @@ -141,7 +141,7 @@ Claude Desktop runs servers in a completely isolated environment with no access If your server needs environment variables (like API keys), you must include them: ```bash -fastmcp install claude-desktop server.py --name "Weather Server" \ +fastmcp install claude-desktop server.py --server-name "Weather Server" \ --env API_KEY=your-api-key \ --env DEBUG=true ``` @@ -149,7 +149,7 @@ fastmcp install claude-desktop server.py --name "Weather Server" \ Or load them from a `.env` file: ```bash -fastmcp install claude-desktop server.py --name "Weather Server" --env-file .env +fastmcp install claude-desktop server.py --server-name "Weather Server" --env-file .env ``` <Warning> - **`uv` must be installed and available in your system PATH**. Claude Desktop runs in its own isolated environment and needs `uv` to manage dependencies. diff --git a/docs/integrations/cursor.mdx b/docs/integrations/cursor.mdx index 787497b02..da0744ee0 100644 --- a/docs/integrations/cursor.mdx +++ b/docs/integrations/cursor.mdx @@ -139,7 +139,7 @@ Cursor runs servers in a completely isolated environment with no access to your If your server needs environment variables (like API keys), you must include them: ```bash -fastmcp install cursor server.py --name "Weather Server" \ +fastmcp install cursor server.py --server-name "Weather Server" \ --env API_KEY=your-api-key \ --env DEBUG=true ``` @@ -147,7 +147,7 @@ fastmcp install cursor server.py --name "Weather Server" \ Or load them from a `.env` file: ```bash -fastmcp install cursor server.py --name "Weather Server" --env-file .env +fastmcp install cursor server.py --server-name "Weather Server" --env-file .env ``` <Warning> @@ -164,10 +164,10 @@ You can generate MCP JSON configuration for manual use: ```bash # Generate configuration and output to stdout -fastmcp install mcp-json server.py --name "Dice Roller" --with pandas +fastmcp install mcp-json server.py --server-name "Dice Roller" --with pandas # Copy configuration to clipboard for easy pasting -fastmcp install mcp-json server.py --name "Dice Roller" --copy +fastmcp install mcp-json server.py --server-name "Dice Roller" --copy ``` This generates the standard `mcpServers` configuration format that can be used with any MCP-compatible client. diff --git a/docs/integrations/descope.mdx b/docs/integrations/descope.mdx index fbe1c2b22..33786b6e2 100644 --- a/docs/integrations/descope.mdx +++ b/docs/integrations/descope.mdx @@ -18,7 +18,7 @@ This guide shows you how to secure your FastMCP server using [**Descope**](https Before you begin, you will need: 1. To [sign up](https://www.descope.com/sign-up) for a Free Forever Descope account -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) +2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:3000`) ### Step 1: Configure Descope @@ -55,26 +55,21 @@ Create a `.env` file with your Descope configuration: ```bash DESCOPE_CONFIG_URL=https://api.descope.com/v1/apps/P.../.well-known/openid-configuration -BASE_URL=http://localhost:8000 +SERVER_URL=http://localhost:3000 # Your server's base URL ``` ### Step 3: FastMCP Configuration -Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically. Nothing reads `.env` automatically, so load it explicitly with [python-dotenv](https://pypi.org/project/python-dotenv/) (`pip install python-dotenv`) before constructing the provider — otherwise the values you just wrote stay invisible to `os.environ`. +Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically: ```python server.py -import os - -from dotenv import load_dotenv from fastmcp import FastMCP from fastmcp.server.auth.providers.descope import DescopeProvider -load_dotenv() - # DescopeProvider accepts either supported Well-Known URL format. auth_provider = DescopeProvider( - config_url=os.environ["DESCOPE_CONFIG_URL"], - base_url=os.environ.get("BASE_URL", "http://localhost:8000"), + config_url="https://api.descope.com/v1/apps/P.../.well-known/openid-configuration", + base_url="https://your-fastmcp-server.com", ) # Create FastMCP server with auth @@ -83,8 +78,6 @@ mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider) ### Scope discovery and validation -<VersionBadge version="4.0.0" /> - When both `scopes_supported` and `required_scopes` are omitted, `DescopeProvider` discovers `scopes_supported` lazily from the OpenID configuration and advertises them to MCP clients. Provider construction remains network-free, and a transient discovery failure is retried on a later metadata request. Set both options when clients should request a broader set of scopes than the server requires on every token: @@ -118,8 +111,7 @@ import asyncio async def main(): async with Client("http://localhost:8000/mcp", auth="oauth") as client: - tools = await client.list_tools() - print(f"Authenticated. Server exposes {len(tools)} tools.") + assert await client.ping() if __name__ == "__main__": asyncio.run(main()) diff --git a/docs/integrations/discord.mdx b/docs/integrations/discord.mdx index 43586d578..5d6c643b7 100644 --- a/docs/integrations/discord.mdx +++ b/docs/integrations/discord.mdx @@ -108,8 +108,7 @@ async def main(): print("✓ Authenticated with Discord!") result = await client.call_tool("get_user_info") - user_info = result.data - print(f"Discord user: {user_info['username']}") + print(f"Discord user: {result['username']}") if __name__ == "__main__": asyncio.run(main()) diff --git a/docs/integrations/gemini-cli.mdx b/docs/integrations/gemini-cli.mdx index 86d35b20b..10613fb1b 100644 --- a/docs/integrations/gemini-cli.mdx +++ b/docs/integrations/gemini-cli.mdx @@ -118,7 +118,7 @@ fastmcp install gemini-cli server.py --project /path/to/my-project If your server needs environment variables (like API keys), you must include them: ```bash -fastmcp install gemini-cli server.py --name "Weather Server" \ +fastmcp install gemini-cli server.py --server-name "Weather Server" \ --env API_KEY=your-api-key \ --env DEBUG=true ``` @@ -126,7 +126,7 @@ fastmcp install gemini-cli server.py --name "Weather Server" \ Or load them from a `.env` file: ```bash -fastmcp install gemini-cli server.py --name "Weather Server" --env-file .env +fastmcp install gemini-cli server.py --server-name "Weather Server" --env-file .env ``` <Warning> diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index d1a2a3608..d493eb1ef 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -69,7 +69,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider # The GitHubProvider handles GitHub's token format and validation auth_provider = GitHubProvider( client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID - client_secret="your-github-client-secret", # Your GitHub OAuth App Client Secret + client_secret="github_pat_...", # Your GitHub OAuth App Client Secret base_url="http://localhost:8000", # Must match your OAuth App configuration # redirect_path="/auth/callback" # Default value, customize if needed ) @@ -151,7 +151,7 @@ from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage auth_provider = GitHubProvider( client_id="Ov23liAbcDefGhiJkLmN", - client_secret="your-github-client-secret", + client_secret="github_pat_...", base_url="https://your-production-domain.com", # Production token management diff --git a/docs/integrations/google.mdx b/docs/integrations/google.mdx index 141444080..17d49d12f 100644 --- a/docs/integrations/google.mdx +++ b/docs/integrations/google.mdx @@ -130,9 +130,8 @@ async def main(): # Test the protected tool result = await client.call_tool("get_user_info") - user_info = result.data - print(f"Google user: {user_info['email']}") - print(f"Name: {user_info['name']}") + print(f"Google user: {result['email']}") + print(f"Name: {result['name']}") if __name__ == "__main__": asyncio.run(main()) diff --git a/docs/v3/integrations/images/permit/role_assignement.png b/docs/integrations/images/permit/role_assignement.png similarity index 100% rename from docs/v3/integrations/images/permit/role_assignement.png rename to docs/integrations/images/permit/role_assignement.png diff --git a/docs/integrations/mcp-json-configuration.mdx b/docs/integrations/mcp-json-configuration.mdx index b516c9954..fec8ffc01 100644 --- a/docs/integrations/mcp-json-configuration.mdx +++ b/docs/integrations/mcp-json-configuration.mdx @@ -70,7 +70,7 @@ An object containing environment variables to set when launching the server. All This format is widely adopted across the MCP ecosystem: -- **Claude Desktop**: Uses `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows +- **Claude Desktop**: Uses `~/.claude/claude_desktop_config.json` - **Cursor**: Uses `~/.cursor/mcp.json` - **VS Code**: Uses workspace `.vscode/mcp.json` - **Other clients**: Many MCP-compatible applications follow this standard @@ -457,7 +457,7 @@ The generated configuration works with any MCP-compatible application: <Note> **Prefer [`fastmcp install claude-desktop`](/integrations/claude-desktop)** for automatic installation. Use MCP JSON for advanced configuration needs. </Note> -Copy the `mcpServers` object into Claude Desktop's config file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows) +Copy the `mcpServers` object into `~/.claude/claude_desktop_config.json` ### Cursor <Note> diff --git a/docs/integrations/oci.mdx b/docs/integrations/oci.mdx index 72165238d..02fa36dae 100644 --- a/docs/integrations/oci.mdx +++ b/docs/integrations/oci.mdx @@ -75,7 +75,7 @@ Follow the Steps as mentioned below to create an OAuth client. Click on "Edit OAuth configuration" button. Configure the application as OAuth client by selecting "Configure this application as a client now" radio button. Select "Authorization code" grant type. If you are planning to use the same OAuth client application for token exchange, select "Client credentials" grant type as well. In the sample, we will use the same client. - For Authorization grant type, select redirect URL. In most cases, this will be the MCP server URL followed by "/auth/callback". + For Authorization grant type, select redirect URL. In most cases, this will be the MCP server URL followed by "/oauth/callback". <Frame> <img src="/integrations/images/oci/ocioauthconfiguration.png" alt="OAuth Configuration for an Integrated Application in OCI IAM Domain" /> diff --git a/docs/integrations/openapi.mdx b/docs/integrations/openapi.mdx index 4769f6576..d2826aa03 100644 --- a/docs/integrations/openapi.mdx +++ b/docs/integrations/openapi.mdx @@ -62,8 +62,9 @@ api_client = httpx2.AsyncClient( # Create MCP server with authenticated client mcp = FastMCP.from_openapi( - openapi_spec=spec, + openapi_spec=spec, client=api_client, + timeout=30.0 # 30 second timeout for all requests ) ``` @@ -403,7 +404,7 @@ FastMCP intelligently handles different types of parameters in OpenAPI requests: ### Query Parameters -By default, FastMCP skips parameters whose value is `None`. Empty strings are still sent as empty query values, which is useful for APIs that distinguish between an omitted parameter and an explicitly blank one. +By default, FastMCP only includes query parameters that have non-empty values. Parameters with `None` values or empty strings are automatically filtered out. ```python # When calling this tool... @@ -411,10 +412,10 @@ await client.call_tool("search_products", { "category": "electronics", # ✅ Included "min_price": 100, # ✅ Included "max_price": None, # ❌ Excluded - "brand": "", # ✅ Included as an empty value + "brand": "", # ❌ Excluded }) -# The HTTP request will be: GET /products?category=electronics&min_price=100&brand= +# The HTTP request will be: GET /products?category=electronics&min_price=100 ``` ### Path Parameters @@ -452,21 +453,4 @@ FastMCP handles array parameters according to OpenAPI specifications: ### Headers -Header parameters are automatically converted to strings and included in the HTTP request. - -### Composed Request Bodies - -A request body becomes a flat set of tool arguments, which is the shape LLM tool-calling APIs fill in most reliably. Schemas composed with `allOf` are resolved first, following `$ref` members, so fields inherited from a parent schema appear alongside the ones a schema declares itself. - -Schemas that use a `discriminator` are flattened the same way. FastMCP merges in the fields of every subtype named in the discriminator's `mapping`, marks them optional, and names the accepted values on the discriminator's own description. Given a `Pet` body discriminated by `petType` and mapped onto `Cat` and `Dog`, the tool takes the discriminator plus whichever fields that variant uses: - -```python -await client.call_tool("create_pet", { - "petType": "cat", - "meowVolume": 11, -}) -``` - -The discriminator stays required; every variant field is optional, because only one variant applies to any given call. - -This trades local strictness for a schema models complete accurately. The generated schema permits any combination of variant fields, so sending `packSize` with `petType: "cat"` passes FastMCP's validation and is rejected by the API itself, exactly as it would be for any other HTTP client. Where two variants declare the same field differently, the declarations are combined with `anyOf` so that neither variant's constraints are advertised as applying to both. \ No newline at end of file +Header parameters are automatically converted to strings and included in the HTTP request. \ No newline at end of file diff --git a/docs/integrations/permit.mdx b/docs/integrations/permit.mdx index 66b8c896e..066f5b1ea 100644 --- a/docs/integrations/permit.mdx +++ b/docs/integrations/permit.mdx @@ -31,7 +31,7 @@ The middleware automatically maps MCP methods to Permit.io resources and actions > **Note:** > Don't forget to assign the relevant role (e.g., Admin, User) to the user authenticating to your MCP server (such as the user in the JWT) in the Permit.io Directory. Without the correct role assignment, users will not have access to the resources and actions you've configured in your policies. > -> ![Permit.io Directory Role Assignment Example](./images/permit/role_assignment.png) +> ![Permit.io Directory Role Assignment Example](./images/permit/role_assignement.png) > > *Example: In Permit.io Directory, both 'client' and 'admin' users are assigned the 'Admin' role, granting them the permissions defined in your policy mapping.* @@ -300,12 +300,10 @@ For advanced configuration options and custom middleware extensions, see [Advanc See the [example server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/example.py) for a full implementation with JWT-based authentication. For additional examples and usage patterns, see [Example Server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/): ```python -import os -import datetime - -import jwt from fastmcp import FastMCP, Context from permit_fastmcp.middleware.middleware import PermitMcpMiddleware +import jwt +import datetime # Configure JWT identity extraction os.environ["PERMIT_MCP_IDENTITY_MODE"] = "jwt" diff --git a/docs/integrations/propelauth.mdx b/docs/integrations/propelauth.mdx index 7875a1ce2..7f21d2010 100644 --- a/docs/integrations/propelauth.mdx +++ b/docs/integrations/propelauth.mdx @@ -101,8 +101,7 @@ import asyncio async def main(): async with Client("http://localhost:8000/mcp", auth="oauth") as client: - tools = await client.list_tools() - print(f"Authenticated. Server exposes {len(tools)} tools.") + assert await client.ping() if __name__ == "__main__": asyncio.run(main()) diff --git a/docs/integrations/scalekit.mdx b/docs/integrations/scalekit.mdx index ac04b46df..2b2fa9d10 100644 --- a/docs/integrations/scalekit.mdx +++ b/docs/integrations/scalekit.mdx @@ -28,12 +28,12 @@ In your Scalekit dashboard: 2. Enter server details: a name, a resource identifier, and the desired MCP client authentication settings 3. Save, then copy the **Resource ID** (for example, res_92015146095) -Record these values in a `.env` file in your FastMCP project: +In your FastMCP project's `.env`: -```sh .env -SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com -SCALEKIT_RESOURCE_ID=res_926EXAMPLE5878 -BASE_URL=http://localhost:8000 +```sh +SCALEKIT_ENVIRONMENT_URL=<YOUR_APP_ENVIRONMENT_URL> +SCALEKIT_RESOURCE_ID=<YOUR_APP_RESOURCE_ID> # res_926EXAMPLE5878 +BASE_URL=http://localhost:8000/ # Optional: additional scopes tokens must have # SCALEKIT_REQUIRED_SCOPES=read,write ``` @@ -43,25 +43,20 @@ BASE_URL=http://localhost:8000 ### Step 2: Add auth to FastMCP server -Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically. Nothing reads `.env` automatically, so load it explicitly with [python-dotenv](https://pypi.org/project/python-dotenv/) (`pip install python-dotenv`) before constructing the provider. +Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically: > **Warning:** The legacy `mcp_url` and `client_id` parameters are deprecated and will be removed in a future release. Use `base_url` instead of `mcp_url` and remove `client_id` from your configuration. ```python server.py -import os - -from dotenv import load_dotenv from fastmcp import FastMCP from fastmcp.server.auth.providers.scalekit import ScalekitProvider -load_dotenv() - -# Discovers Scalekit endpoints and sets up JWT token validation +# Discovers Scalekit endpoints and set up JWT token validation auth_provider = ScalekitProvider( - environment_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], # Scalekit environment URL - resource_id=os.environ["SCALEKIT_RESOURCE_ID"], # Resource server ID - base_url=os.environ.get("BASE_URL", "http://localhost:8000"), - required_scopes=["read"], # Optional scope enforcement + environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL + resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID + base_url=SERVER_URL, # Public MCP endpoint + required_scopes=["read"], # Optional scope enforcement ) # Create FastMCP server with auth @@ -91,7 +86,7 @@ Set `required_scopes` when you need tokens to carry specific permissions. Leave uv run python server.py ``` -Use any MCP client (for example, mcp-inspector, Claude, VS Code, or Windsurf) to connect to the running server. Verify that authentication succeeds and requests are authorized as expected. +Use any MCP client (for example, mcp-inspector, Claude, VS Code, or Windsurf) to connect to the running serve. Verify that authentication succeeds and requests are authorized as expected. ## Production Configuration @@ -104,8 +99,8 @@ from fastmcp.server.auth.providers.scalekit import ScalekitProvider # Load configuration from environment variables auth = ScalekitProvider( - environment_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], - resource_id=os.environ["SCALEKIT_RESOURCE_ID"], + environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"), + resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"), base_url=os.environ.get("BASE_URL", "https://your-server.com") ) diff --git a/docs/integrations/supabase.mdx b/docs/integrations/supabase.mdx index d39696da7..9ffda444d 100644 --- a/docs/integrations/supabase.mdx +++ b/docs/integrations/supabase.mdx @@ -30,8 +30,7 @@ Before you begin, you will need: 2. **OAuth Server enabled** in your Supabase Dashboard (Authentication → OAuth Server) 3. **Dynamic Client Registration enabled** in the same settings 4. A **consent UI** hosted at your configured authorization path (see above) -5. Your Supabase Auth JWT signing algorithm. `SupabaseProvider` defaults to `ES256`; set `algorithm="RS256"` if your project is configured for RSA signing. -6. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) +5. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Enable Supabase OAuth Server @@ -59,7 +58,6 @@ from fastmcp.server.auth.providers.supabase import SupabaseProvider auth = SupabaseProvider( project_url="https://abc123.supabase.co", base_url="http://localhost:8000", - algorithm="ES256", # Match your Supabase Auth JWT signing algorithm ) mcp = FastMCP("Supabase Protected Server", auth=auth) @@ -119,7 +117,6 @@ from fastmcp.server.auth.providers.supabase import SupabaseProvider auth = SupabaseProvider( project_url=os.environ["SUPABASE_PROJECT_URL"], base_url=os.environ.get("BASE_URL", "https://your-server.com"), - algorithm=os.environ.get("SUPABASE_JWT_ALGORITHM", "ES256"), ) mcp = FastMCP(name="Supabase Secured App", auth=auth) diff --git a/docs/language-dropdown.js b/docs/language-dropdown.js deleted file mode 100644 index 4eb5131f6..000000000 --- a/docs/language-dropdown.js +++ /dev/null @@ -1,77 +0,0 @@ -// Language dropdown: a small Python/TypeScript switcher injected into the -// sidebar footer, next to Mintlify's theme selector. Selecting the other -// language navigates to that project's docs site; selecting the current -// language is a no-op. Styling lives in css/language-dropdown.css. -(function () { - if (typeof window === "undefined") return; - - var CURRENT_LANGUAGE = "python"; - - // TODO: fastmcp-ts has no public docs site URL discoverable in either repo - // yet. Until it exists, point at the repo README (the same cross-link the - // welcome page uses), then replace with the real docs URL. - var TYPESCRIPT_DOCS_URL = "https://github.com/PrefectHQ/fastmcp-ts"; - var PYTHON_DOCS_URL = "https://gofastmcp.com"; - - var URLS = { python: PYTHON_DOCS_URL, typescript: TYPESCRIPT_DOCS_URL }; - - function findThemeSelector() { - // Mintlify's sidebar-footer DOM is not a stable public API, so probe a - // few markers (almond theme first) and give up quietly if none match. - return ( - document.querySelector("[data-theme-preference-switch]") || - document.querySelector('[role="group"][aria-label="Theme preference"]') - ); - } - - function buildDropdown() { - var label = document.createElement("label"); - label.id = "language-switch"; - - var select = document.createElement("select"); - select.setAttribute("aria-label", "Switch documentation language"); - - [ - ["python", "Python"], - ["typescript", "TypeScript"], - ].forEach(function (entry) { - var option = document.createElement("option"); - option.value = entry[0]; - option.textContent = entry[1]; - if (entry[0] === CURRENT_LANGUAGE) option.selected = true; - select.appendChild(option); - }); - - select.addEventListener("change", function () { - if (select.value === CURRENT_LANGUAGE) return; - window.location.href = URLS[select.value]; - }); - - label.appendChild(select); - return label; - } - - function addDropdown() { - if (document.getElementById("language-switch")) return; - var theme = findThemeSelector(); - if (!theme || !theme.parentElement) return; - // Insert after the theme pill; margin-left:auto floats it right. - theme.parentElement.insertBefore(buildDropdown(), theme.nextSibling); - } - - function run() { - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", addDropdown); - } else { - addDropdown(); - } - } - - run(); - - // Mintlify re-renders the sidebar on client-side navigation; re-inject when - // the dropdown disappears. - new MutationObserver(function () { - if (!document.getElementById("language-switch")) addDropdown(); - }).observe(document.body, { subtree: true, childList: true }); -})(); diff --git a/docs/more/faq.mdx b/docs/more/faq.mdx index 5566e908f..d2bbbe05e 100644 --- a/docs/more/faq.mdx +++ b/docs/more/faq.mdx @@ -1,124 +1,9 @@ --- title: FAQ -description: Direct answers to the questions that come up most often about FastMCP 4, the protocol eras, and installation +description: Answers to common questions about installing and using FastMCP icon: circle-question --- -## Do I need to change my server code for FastMCP 4? - -Most servers run untouched. The defining change in FastMCP 4 is its engine — the MCP Python SDK v2 — and FastMCP absorbs nearly all of it for you, including the wire-wide rename from camelCase to snake_case, which is bridged so your existing reads keep working. - -Most of what does reach your code fails loudly at import or call time, and the fix is mechanical: `McpError(ErrorData(...))` becomes `McpError(code=..., message=...)`, custom `httpx` clients handed to a transport become `httpx2`, and `ctx.sample()` and `ctx.list_roots()` are gone. - -One change is silent, so go looking for it: an `except httpx.ConnectError:` around a FastMCP call still imports and still type-checks, because `httpx` usually remains installed through some other dependency — but FastMCP now raises the `httpx2` exception, so the handler simply stops matching and your fallback quietly never runs. Grep for `except httpx.` and move those to `httpx2`. [Upgrading from FastMCP 3](/getting-started/upgrading/from-fastmcp-3) covers each one and ends with a checklist. - -## Why does my client connect with a different protocol version than before? - -`fastmcp.Client` defaults to `mode="auto"` as of FastMCP 4, so it negotiates the newest era both sides speak rather than pinning the handshake. Over streamable HTTP or stdio to a FastMCP server that means the sessionless `2026-07-28` protocol, where FastMCP 3 connected at `2025-11-25`. - -Two transports are exceptions: SSE predates the sessionless era and cannot carry it, and a multi-server `MCPConfigTransport` mounts each backend behind a legacy-era composite. Under `mode="auto"` the client recognizes both and settles on the handshake without probing, so seeing `2025-11-25` there is correct rather than a negotiation failure. Pinning a modern version explicitly on either skips that substitution and asks the transport for something it cannot serve, so leave them on auto or legacy. - -The client probes `server/discover` and adopts the modern protocol when the server answers, falling back to the `initialize` handshake for anything that is not positive evidence of a modern peer — so a mixed fleet of servers still connects. Pin the old behavior per client with `Client(url, mode="legacy")`. See [Protocol negotiation](/clients/client#protocol-negotiation). - -## What are the two protocol eras, and which one does my server speak? - -Both. A FastMCP 4 server supports the handshake revisions `2024-11-05`, `2025-03-26`, `2025-06-18`, and `2025-11-25`, plus the modern `2026-07-28` protocol. It serves all of them from one deployment and one URL, and the SDK negotiates per connection — the client picks, not the server. - -The *handshake* era (`2025-11-25` and earlier) opens each connection with `initialize` and holds a session, which gives the server a back-channel it can push requests down. The *modern* era (`2026-07-28`) is sessionless: the client learns what the server offers through `server/discover`, every request stands alone, and there is no back-channel. Inside a tool, `ctx.request_context.protocol_version` tells you which era the current call arrived on; on the client, `client.protocol_version` reports it after connecting. - -A protocol version establishes the wire format, while capabilities describe which optional operations a particular server provides. The capabilities returned by `server/discover` or `initialize` are therefore the authoritative way for a client to determine what is available. - -## Can FastMCP 4 talk to older clients and servers? - -Yes, in both directions, with no configuration. A FastMCP 4 server answers a handshake-era client and a modern one from the same process: the old client sends `initialize` and gets a session id, the modern client discovers and stays stateless. - -A FastMCP 4 client is equally happy against an old server, because `mode="auto"` falls back to the handshake when discovery finds no modern peer. The client-side handlers for server-initiated capabilities are all still there too — passing `sampling_handler=` or `roots=` answers a legacy server's requests exactly as before, which is what a modern client needs in order to interoperate. See [client sampling](/clients/sampling) and [client roots](/clients/roots). - -## How does FastMCP verify protocol conformance? - -FastMCP runs the [official MCP conformance suite](https://github.com/modelcontextprotocol/conformance) in CI against a pinned suite release. A failing scenario for a released capability that FastMCP advertises as supported is treated as a regression. - -The suite's `all` mode also exercises draft, pending, retired, and deliberately unsupported capabilities, so its raw pass count is broader than FastMCP's support contract. Known exceptions are recorded in [`expected-failures.yml`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/conformance/expected-failures.yml) with their rationale, and new upstream scenarios arrive through deliberate suite-version updates rather than silently changing CI. - -## When should I pin `mode="legacy"`? - -Pin it when your code depends on the session the handshake creates: `client.ping()` and `transport.get_session_id()` have no modern equivalent, since a sessionless connection has neither a live back-channel to ping nor an id to hold. It is also the escape hatch when a server misbehaves under discovery or you need the classic `initialize` result object. - -You do not need to pin it just because you registered a `sampling_handler`, `roots=`, or an `elicitation_handler`. None of the three require the handshake on their own: `mode="auto"` reaches whichever era the connection negotiates, and on a modern connection a tool can still exercise any of them through the guard pattern — it manually returns an `InputRequiredResult` embedding the request, and the same handler you already registered answers it. [Roots](/clients/roots) and [elicitation](/clients/elicitation) document this pattern directly; [sampling](/clients/sampling) works through the identical mechanism, though calling an LLM directly from the server is the recommended path there rather than a round trip for it. - -Pinning is per client, not a deployment setting: `Client(url, mode="legacy")`. The trade runs the other way as well — [background tasks](/clients/tasks) are modern-only, so a legacy client never triggers one and a task-enabled tool simply runs synchronously. - -## Why did my `ctx.sample()` code stop working? - -`ctx.sample()` and `ctx.sample_step()` are not part of FastMCP 4. Calling either raises `AttributeError` on every protocol era, and `FastMCP(sampling_handler=...)` raises `TypeError` naming the migration. - -Sampling was a server-to-client *request*: the server sent `sampling/createMessage` and blocked until an answer came back down the session. The modern protocol has no server-to-client request direction at all, so the pushed form has nowhere to go. - -The asking survives in a different shape. A tool can return an `InputRequiredResult` carrying a `CreateMessageRequest`; the client answers it through the same `sampling_handler` it already registers, and your tool runs again with the completion. Reach for that when using *the caller's* model is the point. Otherwise put generation in your server — hold a provider API key and call the model directly, which has the side benefit that your tool behaves identically for every client, including the many that never implemented sampling. [Sampling](/servers/sampling) shows both. - -## What happened to `ctx.list_roots()`? - -Removed, for the same reason as sampling: `roots/list` was a server-to-client request, and the modern protocol has no channel to send one. - -Take the paths you need as ordinary tool arguments. The agent already knows which directory it is working in, and an explicit argument is visible in the tool's schema instead of hidden in a protocol round-trip. When the caller genuinely has to be asked mid-run, the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) carries a roots request in its `input_requests` map alongside elicitation, and `fastmcp.Client` answers it from the `roots=` you already configured. - -## Why does `ctx.info()` still work when sampling doesn't? - -Because logging is a *notification* and sampling was a *request*. A notification is fire-and-forget: your server emits it down the response stream the caller already opened, and nothing has to be held open on the server's behalf. A request needs an answer to come back the other way, which requires a live connection the server can reach into. - -The modern protocol kept every server notification — `notifications/message`, `notifications/progress`, and the list-changed family — and removed the server-to-client request direction entirely. So `ctx.info()`, `ctx.debug()`, and `ctx.report_progress()` reach the client mid-call on every era, while sampling and roots have no era-agnostic form and were dropped. [Sampling](/servers/sampling#the-removed-methods) works through the distinction in full. - -You may see an `MCPDeprecationWarning` from the SDK about the logging capability being deprecated as of `2026-07-28`. It refers to the capability declaration, not to the notification, and delivery is unaffected. - -## Why can't I call `client.set_logging_level()` anymore? - -On a modern connection it raises, because `logging/setLevel` is not in the `2026-07-28` protocol. The method asked the server to remember a level for the rest of the session, and a sessionless protocol has nowhere to keep that. - -The messages themselves are unaffected — the server still sends whatever its own configuration allows. Filter on the receiving side in your `log_handler`, which sees each message's `level` field. See [Client Logging](/clients/logging). On a handshake-era connection (`Client(url, mode="legacy")`) the call works as before. - -Receiving-side filtering only narrows what already arrives. A server that sets `FastMCP(client_log_level="error")` drops anything below that threshold before it reaches the wire, and a modern client has no way to ask for the missing levels — the server operator has to lower `client_log_level` for them to be sent at all. - -## What replaces elicitation on the modern protocol? - -The guard pattern. Rather than pausing mid-execution to ask, a tool *returns* an `InputRequiredResult` describing what it needs. That round completes normally, the client collects the answer, and it calls the tool again with the answer attached. Any state you carry between rounds is sealed by the framework before it reaches the wire, so the client holds an opaque token it cannot read or forge. - -`ctx.elicit()` still works on handshake-era connections and raises on modern ones, so a server that must serve both eras needs both paths. `fastmcp.Client` drives whichever the connection negotiated with no extra wiring on your side. See [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol). - -## Why doesn't my middleware's `on_initialize` hook run? - -Because the modern protocol has no `initialize` request. The hook fires on handshake-era connections and never on modern ones, and since `Client` now defaults to `mode="auto"`, that is the common case against a FastMCP 4 server. - -Work that must happen once per process belongs in the server [lifespan](/servers/lifespan). Per-request work such as an auth check belongs in `on_request` or a specific operation hook, both of which run on every era — on a modern connection `on_request` sees `server/discover` where a handshake connection sees `initialize`. See [Middleware](/servers/middleware). - -## Why doesn't state I set in one tool call show up in the next? - -On a modern connection every request is a fresh connection, so `ctx.set_state` lives only for the duration of the call that wrote it. The same code persists state across calls on a handshake-era connection, which is why it appears to break the moment a client negotiates `2026-07-28`. - -[Session state](/servers/sessions) is the durable answer, following MCP's own decision to move session semantics up into the application. Declare a `UserSession` parameter and FastMCP injects one bucket of stored state keyed to the authenticated user, with nothing to pass around. Declare a `SessionId` argument when a single user needs several independent sessions, and the caller mints an id with `create_session` and supplies it on each call — register `mcp.add_provider(SessionProvider())` first, since `create_session` doesn't exist until a `SessionProvider` contributes it. Both store server-side and key to the authenticated caller's identity, so a handle is inert in anyone else's hands. - -That isolation comes from authentication, not from the id. On an unauthenticated server there is no principal to key on, so every session shares one anonymous namespace and a `SessionId` becomes a bearer capability — anyone holding it can read and write that state. Treat unauthenticated sessions as single-tenant or trusted-network only; `UserSession` sidesteps the question by requiring an authenticated principal outright. - -## How do I run background tasks now? - -The same way, plus one registration. `@mcp.tool(task=True)` is still the authoring surface and [Docket](https://github.com/chrisguidry/docket) still runs the work. What changed is underneath: tasks left the core MCP spec and returned as the `io.modelcontextprotocol/tasks` extension (SEP-2663), which FastMCP implements in the optional `fastmcp-tasks` package. - -Install `fastmcp[tasks]` and register the extension on your server. A `task=True` tool on a server with no tasks extension refuses to start and names the fix, so a missing registration is impossible to ship by accident. - -```python -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension - -mcp = FastMCP("MyServer") -mcp.add_extension(TasksExtension()) - - -@mcp.tool(task=True) -async def slow_computation(duration: int) -> str: - return "done" -``` - -Tasks are modern-only: the capability is negotiated over `2026-07-28`, so a `mode="legacy"` client never triggers one. A tool marked `task=True` (equivalently `mode="optional"`) then just runs synchronously. A tool that sets `TaskConfig(mode="required")` has no synchronous form to fall back to, so the call fails with a missing-required-capability error instead. See [Background Tasks](/servers/tasks). - ## `import fastmcp` stopped working after I upgraded with pip This can happen when you upgrade to FastMCP 3.3 or later from FastMCP 3.2 or earlier with `pip`. The quick fix is `pip install --force-reinstall fastmcp`. See [Troubleshooting](/getting-started/installation#troubleshooting) for the clean-reinstall fallback and an explanation of why it happens. diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index e6ea0f584..b672af316 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -4,7 +4,7 @@ description: Configure FastMCP behavior through environment variables or a .env icon: gear --- -FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file. +FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file (see the [Tasks (Docket)](#tasks-docket) section for a caveat about nested settings in `.env` files). ```bash # Set via environment @@ -23,7 +23,7 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env |---|---|---|---| | `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level for FastMCP's own logging output. Case-insensitive. | | `FASTMCP_LOG_ENABLED` | `bool` | `true` | Enable or disable FastMCP logging entirely. | -| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. | +| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. | | `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. | | `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. | | `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. | @@ -63,7 +63,6 @@ These control how the server listens when running with an HTTP transport. |---|---|---|---| | `FASTMCP_CLIENT_INIT_TIMEOUT` | `float \| None` | None | Timeout in seconds for the client initialization handshake. Set to `0` or leave unset to disable. | | `FASTMCP_CLIENT_DISCONNECT_TIMEOUT` | `float` | `5` | Maximum time in seconds to wait for a clean disconnect before giving up. | -| `FASTMCP_TASKS_CLIENT_POLL_INTERVAL` | `float` | `0.5` | Ceiling in seconds for the fallback poll backoff while waiting on a [background task](/servers/tasks). Requires the `fastmcp-tasks` package. Applies **only** when the server does not advertise its own `pollInterval`: in that case `Task.wait()` starts polling fast (~20ms) and doubles up to this ceiling rather than polling at a fixed cadence. When the server advertises a `pollInterval`, that interval is honored exactly and this setting is ignored. | | `FASTMCP_CLIENT_RAISE_FIRST_EXCEPTIONGROUP_ERROR` | `bool` | `true` | When an `ExceptionGroup` is raised, re-raise the first error directly instead of the group. Simplifies debugging but may mask secondary errors. | ## CLI & Display @@ -77,31 +76,25 @@ These control how the server listens when running with an HTTP transport. | Environment Variable | Type | Default | Description | |---|---|---|---| -| `FASTMCP_TELEMETRY_MODE` | `Literal["native", "propagation_only", "off"]` | `native` | Controls FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry). `native` emits FastMCP's MCP spans and propagates trace context; because FastMCP uses only the OpenTelemetry API, this costs almost nothing unless an SDK and exporter are configured. `propagation_only` keeps `_meta` trace propagation and still parents downstream spans from the incoming context, but emits none of FastMCP's own spans, so another instrumentation layer can own the MCP span hierarchy. `off` is a full pass-through: no spans, and no trace context extracted or attached. | +| `FASTMCP_ENABLE_TELEMETRY` | `bool` | `true` | Whether FastMCP's native [OpenTelemetry instrumentation](/servers/telemetry) is active. Enabled by default; FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry SDK and exporter are configured. Set to `false` to turn instrumentation off entirely, in which case no FastMCP spans are created even when an SDK is configured. | ## Tasks (Docket) -Task settings (the `FASTMCP_DOCKET_` and `FASTMCP_TASKS_` variables) live in the optional `fastmcp-tasks` package. See [server tasks](/servers/tasks) for configuration, including `FASTMCP_TASKS_ENCRYPTION_KEY` for [encrypting task snapshots at rest](/servers/tasks#credentials-at-rest). +These configure the [Docket](https://github.com/prefecthq/docket) task queue used by [server tasks](/servers/tasks). All use the `FASTMCP_DOCKET_` prefix. -## Security - -These control FastMCP's SSRF protection for the outbound fetches it makes during authentication (OAuth client metadata and JWKS). +<Warning> +When setting Docket values in a `.env` file, use a **double** underscore: `FASTMCP_DOCKET__URL` (not `FASTMCP_DOCKET_URL`). This is because `.env` values are resolved through the parent `Settings` class, which uses `__` as its nested delimiter. As regular environment variables (e.g., `export`), the single-underscore form `FASTMCP_DOCKET_URL` works fine. +</Warning> | Environment Variable | Type | Default | Description | |---|---|---|---| -| `FASTMCP_SSRF_TRUST_PROXY` | `bool` | `false` | Trust an outbound HTTP proxy for SSRF-protected fetches. When `false`, FastMCP resolves the target hostname itself and refuses to connect if it maps to a private, loopback, link-local, or reserved IP. When `true`, FastMCP routes auth metadata and JWKS fetches through the configured `HTTPS_PROXY`/`ALL_PROXY` and does not honor `NO_PROXY`; if no proxy is configured the fetch is refused. | - -By default, FastMCP protects its OAuth and JWKS fetches against [SSRF](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) by resolving the target hostname, rejecting any address that maps to a private, loopback, link-local, or reserved IP, and then pinning the connection to that validated IP. - -This breaks when a corporate `CONNECT` proxy is the only egress path: the container often cannot resolve external DNS at all (only the proxy can), and even when it can, pinning to the IP makes TLS verification fail because public certificates list hostnames, not IP addresses. - -Set `FASTMCP_SSRF_TRUST_PROXY=true` when a trusted proxy is your mandated egress. FastMCP then skips DNS resolution and the IP blocklist entirely and makes a single request to the hostname URL, explicitly routed through the proxy named by the standard `HTTPS_PROXY` / `ALL_PROXY` environment variables (checked in that order). The HTTPS-only and hostname checks still apply. - -<Warning> -This is a deliberate trust shift: the IP blocklist cannot be enforced through a proxy (the proxy does its own DNS, so an address FastMCP resolved is not the one the proxy dials). Only enable it when the proxy itself is trusted to mediate egress. - -FastMCP reads the proxy URL from the environment and passes it to the HTTP client explicitly, with the client's own environment-based proxy routing turned off — so the request either goes through that exact proxy or fails outright, with no routing decision left for the client to make on its own. One consequence: `NO_PROXY` is **not honored** in this mode. A host that `NO_PROXY` would otherwise exclude is still routed through the configured proxy rather than fetched direct with the IP blocklist disabled — the safer of the two options, since the blocklist cannot apply to a direct fetch here anyway. If you set `FASTMCP_SSRF_TRUST_PROXY=true` but neither `HTTPS_PROXY` nor `ALL_PROXY` is present in the server process's environment (an `HTTP_PROXY` alone never routes these HTTPS-only fetches), the request would otherwise go out **direct with the IP blocklist disabled** — no SSRF protection at all. Rather than send it, FastMCP refuses the fetch and raises `SSRFError` with an actionable message. The contract is crisp: proxy-trust mode delegates SSRF protection to the proxy, and with no proxy configured the fetch cannot proceed. Enable this setting only together with an active proxy that routes your auth endpoints. -</Warning> +| `FASTMCP_DOCKET_NAME` | `str` | `fastmcp` | Queue name. Servers and workers sharing the same name and backend URL share a task queue. | +| `FASTMCP_DOCKET_URL` | `str` | `memory://` | Backend URL. Use `memory://` for single-process or `redis://host:port/db` for distributed workers. | +| `FASTMCP_DOCKET_WORKER_NAME` | `str \| None` | None | Worker name. Auto-generated if unset. | +| `FASTMCP_DOCKET_CONCURRENCY` | `int` | `10` | Maximum concurrent tasks per worker. | +| `FASTMCP_DOCKET_REDELIVERY_TIMEOUT` | `timedelta` | `300s` | If a worker doesn't complete a task within this time, it's redelivered to another worker. | +| `FASTMCP_DOCKET_RECONNECTION_DELAY` | `timedelta` | `5s` | Delay between reconnection attempts when the worker loses its backend connection. | +| `FASTMCP_DOCKET_MINIMUM_CHECK_INTERVAL` | `timedelta` | `50ms` | How frequently the worker polls for new tasks. Lower values reduce latency at the cost of more CPU usage. | ## Advanced diff --git a/docs/v3/patterns/cli.mdx b/docs/patterns/cli.mdx similarity index 100% rename from docs/v3/patterns/cli.mdx rename to docs/patterns/cli.mdx diff --git a/docs/patterns/contrib.mdx b/docs/patterns/contrib.mdx index 648cf8722..04ef45aff 100644 --- a/docs/patterns/contrib.mdx +++ b/docs/patterns/contrib.mdx @@ -30,12 +30,12 @@ from fastmcp.contrib import my_module ## Contributing -Contrib modules are accepted selectively. Before opening a PR, first open an issue with the problem, intended maintenance model, and why the pattern belongs in-repo instead of a standalone package. If maintainers agree it belongs in `contrib`, prepare the module with: +We welcome contributions to the `contrib` package! If you have a module that extends FastMCP in a useful way, consider contributing it: 1. Create a new directory in `fastmcp_slim/fastmcp/contrib/` for your module -2. Add proper tests for your module in `tests/contrib/` -3. Include comprehensive documentation in a README.md file, including usage and examples, as well as any additional dependencies or installation instructions -4. Submit a focused pull request linked to the maintainer-approved issue +3. Add proper tests for your module in `tests/contrib/` +2. Include comprehensive documentation in a README.md file, including usage and examples, as well as any additional dependencies or installation instructions +5. Submit a pull request The ideal contrib module: - Solves a specific use case or integration need diff --git a/docs/v3/patterns/testing.mdx b/docs/patterns/testing.mdx similarity index 100% rename from docs/v3/patterns/testing.mdx rename to docs/patterns/testing.mdx diff --git a/docs/python-sdk-pages.json b/docs/python-sdk-pages.json index 32abc995c..6d71d0d53 100644 --- a/docs/python-sdk-pages.json +++ b/docs/python-sdk-pages.json @@ -31,33 +31,10 @@ } ] }, - { - "group": "fastmcp.server", - "pages": [ - "python-sdk/fastmcp-server-caching", - "python-sdk/fastmcp-server-completions", - "python-sdk/fastmcp-server-context", - "python-sdk/fastmcp-server-dependencies", - "python-sdk/fastmcp-server-elicitation", - "python-sdk/fastmcp-server-event_store", - "python-sdk/fastmcp-server-extensions", - "python-sdk/fastmcp-server-http", - "python-sdk/fastmcp-server-lifespan", - "python-sdk/fastmcp-server-low_level", - "python-sdk/fastmcp-server-mixins", - "python-sdk/fastmcp-server-providers", - "python-sdk/fastmcp-server-server", - "python-sdk/fastmcp-server-session_scoped_event_store", - "python-sdk/fastmcp-server-sessions", - "python-sdk/fastmcp-server-telemetry", - "python-sdk/fastmcp-server-transforms" - ] - }, { "group": "fastmcp.utilities", "pages": [ "python-sdk/fastmcp-utilities-__init__", - "python-sdk/fastmcp-utilities-asgi_transport", "python-sdk/fastmcp-utilities-async_utils", "python-sdk/fastmcp-utilities-auth", "python-sdk/fastmcp-utilities-authorization", @@ -101,7 +78,6 @@ "python-sdk/fastmcp-utilities-mime", "python-sdk/fastmcp-utilities-openapi", "python-sdk/fastmcp-utilities-pagination", - "python-sdk/fastmcp-utilities-prefab", "python-sdk/fastmcp-utilities-skills", "python-sdk/fastmcp-utilities-tasks", "python-sdk/fastmcp-utilities-tests", diff --git a/docs/python-sdk/fastmcp-apps-app.mdx b/docs/python-sdk/fastmcp-apps-app.mdx index 4d2e8a421..4b1092309 100644 --- a/docs/python-sdk/fastmcp-apps-app.mdx +++ b/docs/python-sdk/fastmcp-apps-app.mdx @@ -35,7 +35,7 @@ Usage:: ## Classes -### `FastMCPApp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L149" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `FastMCPApp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L144" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> A Provider that represents an MCP application. @@ -48,19 +48,19 @@ can find them by original name even when transforms have been applied. **Methods:** -#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L173" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L168" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python tool(self, name_or_fn: F) -> F ``` -#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L180" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python tool(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L196" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L191" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Any @@ -83,19 +83,19 @@ Supports multiple calling patterns:: def save(name: str): ... -#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L266" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L258" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python ui(self, name_or_fn: F) -> F ``` -#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L281" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L273" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python ui(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` -#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L295" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `ui` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L287" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python ui(self, name_or_fn: str | AnyFunction | None = None) -> Any @@ -119,7 +119,7 @@ Supports multiple calling patterns:: def dashboard() -> Component: ... -#### `add_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L373" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `add_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L362" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -130,13 +130,13 @@ Add a tool to this app programmatically. The tool is tagged with this app's name for routing. -#### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L432" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L418" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python lifespan(self) -> AsyncIterator[None] ``` -#### `run` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L440" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `run` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/app.py#L426" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python run(self, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, **kwargs: Any) -> None diff --git a/docs/python-sdk/fastmcp-apps-config.mdx b/docs/python-sdk/fastmcp-apps-config.mdx index a7b9d6151..9d0c3acf1 100644 --- a/docs/python-sdk/fastmcp-apps-config.mdx +++ b/docs/python-sdk/fastmcp-apps-config.mdx @@ -15,7 +15,7 @@ UI metadata for clients that support interactive app rendering. ## Functions -### `app_config_to_meta_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L181" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `app_config_to_meta_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L180" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any] @@ -25,32 +25,9 @@ app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any] Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``. -### `is_model_visible` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L188" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_model_visible(component: FastMCPComponent) -> bool -``` - - -Whether a component may be shown to, or invoked by, the model. - -Visibility is a declaration, and the MCP Apps spec puts the filtering on -the host — so ``tools/list`` carries app-only tools and the host keeps -them from the model. That division only works where a host stands between -the server and the model. - -It does not hold for surfaces a server drives itself. A search result or -a code-mode catalog reaches the model as ordinary tool output, and a -call-tool proxy invokes on a name the model supplies; nothing downstream -can filter either. Those surfaces have to apply the declaration here. - -A component with no ``visibility`` is visible: the field marks the -exception, and the spec's default is both audiences. - - ## Classes -### `ResourceCSP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L21" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `ResourceCSP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L20" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Content Security Policy for MCP App resources. @@ -60,7 +37,7 @@ load resources from. Hosts use these declarations to build the ``Content-Security-Policy`` header for the sandboxed iframe. -### `ResourcePermissions` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L57" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `ResourcePermissions` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L56" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Iframe sandbox permissions for MCP App resources. @@ -71,7 +48,7 @@ iframe. Hosts MAY honour these; apps should use JS feature detection as a fallback. -### `AppConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L85" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `AppConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L84" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Configuration for MCP App tools and resources. @@ -86,7 +63,7 @@ values appear on the wire. Aliases match the MCP Apps wire format (camelCase). -### `PrefabAppConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `PrefabAppConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L124" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> App configuration for Prefab tools with sensible defaults. @@ -106,7 +83,7 @@ Example:: **Methods:** -#### `model_post_init` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L141" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `model_post_init` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/config.py#L140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python model_post_init(self, __context: Any) -> None diff --git a/docs/python-sdk/fastmcp-dependencies.mdx b/docs/python-sdk/fastmcp-dependencies.mdx index 310511e56..f27566ef3 100644 --- a/docs/python-sdk/fastmcp-dependencies.mdx +++ b/docs/python-sdk/fastmcp-dependencies.mdx @@ -12,7 +12,6 @@ This module re-exports dependency injection symbols to provide a clean, centralized import location for all dependency-related functionality. DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket -using the uncalled-for DI engine. The docket-specific dependencies -(``CurrentDocket``, ``CurrentWorker``) live in the ``fastmcp-tasks`` package -(``fastmcp_tasks.dependencies``). +using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, +CurrentWorker) and background task execution require fastmcp[tasks]. diff --git a/docs/python-sdk/fastmcp-exceptions.mdx b/docs/python-sdk/fastmcp-exceptions.mdx index 151f0f10a..eb554c3fe 100644 --- a/docs/python-sdk/fastmcp-exceptions.mdx +++ b/docs/python-sdk/fastmcp-exceptions.mdx @@ -8,117 +8,74 @@ sidebarTitle: exceptions Custom exceptions for FastMCP. -## Functions - -### `to_mcp_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L117" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -to_mcp_error(exc: Exception) -> MCPError -``` - - -Translate a FastMCP exception into a wire-format ``MCPError``. - -Central mapping from FastMCP's public exception types to the JSON-RPC error -codes defined by the MCP spec (imported from ``mcp_types``). Request-handler -adapters call this instead of hand-rolling ``MCPError(code=..., ...)`` per -call site, so the wire codes stay spec-correct and consistent across -resources, prompts, and tools. - -``NotFoundError`` and ``DisabledError`` map to ``INVALID_PARAMS`` (-32602): -per SEP-2164 a request naming a component that does not exist (or is -disabled) is an invalid-params error, which matches the SDK's own -``ResourceNotFoundError -> INVALID_PARAMS`` mapping in ``mcp.server.mcpserver``. -``ValidationError`` is also an invalid-params error. Everything else falls -back to ``default_code`` (``INTERNAL_ERROR`` by default). - -If ``exc`` is already an ``MCPError``, it is returned unchanged so an -explicit code chosen upstream survives translation. - - ## Classes -### `FastMCPError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `FastMCPDeprecationWarning` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L13" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> + + +Deprecation warning for FastMCP APIs. + +Subclass of DeprecationWarning so that standard warning filters +still apply, but FastMCP can selectively enable its own warnings +without affecting other libraries in the process. + + +### `FastMCPError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L22" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Base error for FastMCP. -### `ValidationError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `ValidationError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L30" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Error in validating parameters or return values. -### `ResourceError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L50" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `ResourceError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Error in resource operations. -### `ToolError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `ToolError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Error in tool operations. -### `PromptError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L58" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `PromptError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Error in prompt operations. -### `InvalidSignature` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L62" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `InvalidSignature` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Invalid signature for use with FastMCP. -### `ClientError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L66" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `ClientError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L50" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Error in client operations. -### `NotFoundError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L70" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `NotFoundError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Object not found. -### `DisabledError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L74" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `DisabledError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L58" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Object is disabled. -### `ResourceSecurityError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L78" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -A templated resource parameter failed path-security screening. - -Subclasses ``NotFoundError`` so the read handler surfaces a -non-leaky ``INVALID_PARAMS`` (-32602) "resource not found" error to -the client — a traversal attempt is indistinguishable from a request -for a resource that does not exist, and never reveals which parameter -or policy tripped. - - -### `AuthorizationError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L89" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `AuthorizationError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L62" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Error when authorization check fails. - -### `InsufficientScopeError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/exceptions.py#L93" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Authorization failed because the token is missing required OAuth scopes. - -Unlike a bare ``AuthorizationError``, this carries the specific scopes the -caller must obtain. A component-level scope shortfall can then be signalled -as a spec-correct ``insufficient_scope`` step-up (SEP-2350 / RFC 6750 §3), -naming exactly what to re-authorize for instead of an opaque denial. The -named scopes are only the *unmet* ones, so an existing grant is accumulated -rather than replaced when the caller re-authorizes. - diff --git a/docs/python-sdk/fastmcp-mcp_config.mdx b/docs/python-sdk/fastmcp-mcp_config.mdx index 70f0978ff..0302d9270 100644 --- a/docs/python-sdk/fastmcp-mcp_config.mdx +++ b/docs/python-sdk/fastmcp-mcp_config.mdx @@ -32,7 +32,7 @@ Example configuration: ## Functions -### `infer_transport_type_from_url` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L55" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `infer_transport_type_from_url` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse'] @@ -42,7 +42,7 @@ infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse'] Infer the appropriate transport type from the given URL. -### `update_config_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L376" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `update_config_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L363" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python update_config_file(file_path: Path, server_name: str, server_config: CanonicalMCPServerTypes) -> None @@ -57,7 +57,7 @@ worry about transforming server objects here. ## Classes -### `StdioMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L180" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `StdioMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L168" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> MCP server configuration for stdio transport. @@ -67,19 +67,19 @@ This is the canonical configuration format for MCP servers using stdio transport **Methods:** -#### `to_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L213" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `to_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L201" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -to_transport(self) -> StdioTransport | FastMCPTransport +to_transport(self) -> StdioTransport ``` -### `TransformingStdioMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L225" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `TransformingStdioMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L213" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> A Stdio server with tool transforms. -### `RemoteMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L229" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `RemoteMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L217" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> MCP server configuration for HTTP/SSE transport. @@ -89,19 +89,19 @@ This is the canonical configuration format for MCP servers using remote transpor **Methods:** -#### `to_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L265" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `to_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L253" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -to_transport(self) -> StreamableHttpTransport | SSETransport | FastMCPTransport +to_transport(self) -> StreamableHttpTransport | SSETransport ``` -### `TransformingRemoteMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L294" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `TransformingRemoteMCPServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L281" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> A Remote server with tool transforms. -### `MCPConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L305" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `MCPConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L292" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> A configuration object for MCP Servers that conforms to the canonical MCP configuration format @@ -113,7 +113,7 @@ For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class. **Methods:** -#### `wrap_servers_at_root` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L319" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `wrap_servers_at_root` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L306" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] @@ -122,7 +122,7 @@ wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] If there's no mcpServers key but there are server configs at root, wrap them. -#### `add_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L332" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `add_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L319" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python add_server(self, name: str, server: MCPServerTypes) -> None @@ -131,7 +131,7 @@ add_server(self, name: str, server: MCPServerTypes) -> None Add or update a server in the configuration. -#### `from_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L337" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `from_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L324" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python from_dict(cls, config: dict[str, Any]) -> Self @@ -140,7 +140,7 @@ from_dict(cls, config: dict[str, Any]) -> Self Parse MCP configuration from dictionary format. -#### `to_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L341" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `to_dict` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L328" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python to_dict(self) -> dict[str, Any] @@ -149,7 +149,7 @@ to_dict(self) -> dict[str, Any] Convert MCPConfig to dictionary format, preserving all fields. -#### `write_to_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L345" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `write_to_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L332" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python write_to_file(self, file_path: Path) -> None @@ -158,7 +158,7 @@ write_to_file(self, file_path: Path) -> None Write configuration to JSON file. -#### `from_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L351" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `from_file` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L338" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python from_file(cls, file_path: Path) -> Self @@ -167,7 +167,7 @@ from_file(cls, file_path: Path) -> Self Load configuration from JSON file. -### `CanonicalMCPConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L361" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `CanonicalMCPConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L348" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Canonical MCP configuration format. @@ -178,7 +178,7 @@ The format is designed to be client-agnostic and extensible for future use cases **Methods:** -#### `add_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L371" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `add_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/mcp_config.py#L358" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python add_server(self, name: str, server: CanonicalMCPServerTypes) -> None diff --git a/docs/python-sdk/fastmcp-server-caching.mdx b/docs/python-sdk/fastmcp-server-caching.mdx deleted file mode 100644 index d4e76a033..000000000 --- a/docs/python-sdk/fastmcp-server-caching.mdx +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: caching -sidebarTitle: caching ---- - -# `fastmcp.server.caching` - - -Server-level cache hints for FastMCP (SEP-2549). - -A FastMCP server opts every SDK-cacheable result it emits into client-side -caching by setting `cache_ttl` (seconds) and, optionally, `cache_scope` on the -`FastMCP` constructor. The hint is uniform by construction: one server-level -value applies to `tools/list`, `prompts/list`, `resources/list`, -`resources/templates/list`, `resources/read`, and `server/discover` alike — no -per-component surface and no aggregation. - -FastMCP does not hand-set the wire fields. It passes the hint through to the SDK -low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on -every cacheable result via `apply_cache_hint`, leaving any field a handler set -explicitly untouched. Honoring is modern-only and opt-in on the client: a hinted -server is inert unless the client passes `cache=` and negotiates `2026-07-28`. - - -## Functions - -### `build_cache_hints` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/caching.py#L29" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -build_cache_hints(cache_ttl: int | None, cache_scope: CacheScope | None) -> dict[CacheableMethod, CacheHint] | None -``` - - -Build the per-method `CacheHint` map for the SDK low-level server. - -`cache_ttl` is in seconds and is converted to the wire's milliseconds. When -`cache_ttl` is `None` the server emits no hint, so its wire output is -identical to a server that never set one; a `cache_scope` given without a -`cache_ttl` is meaningless (the client gates caching on the presence of a -TTL) and is rejected rather than silently ignored. - -Returns `None` when no hint is set, or a map applying the same hint to every -SDK-cacheable method otherwise. - -**Raises:** -- `ValueError`: If `cache_ttl` is not positive, or if `cache_scope` is set -without `cache_ttl`. - diff --git a/docs/python-sdk/fastmcp-server-completions.mdx b/docs/python-sdk/fastmcp-server-completions.mdx deleted file mode 100644 index dcea2c00d..000000000 --- a/docs/python-sdk/fastmcp-server-completions.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: completions -sidebarTitle: completions ---- - -# `fastmcp.server.completions` - - -Server-side argument completion for FastMCP. - -A completion request names a reference — a specific prompt or resource -template — and the argument being completed, plus a context of the argument -values already supplied. The server answers with candidate string values. - -FastMCP surfaces this as a single server-level handler registered with -``@mcp.completion``, mirroring the MCP SDK's own ``completion/complete`` shape -and FastMCP's client-side ``Client.complete()``. The handler receives the -reference, the argument, and the optional context, and returns candidates for -whichever reference/argument pair it recognizes. - - -## Functions - -### `normalize_completion` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/completions.py#L54" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -normalize_completion(result: CompletionValues) -> mcp_types.Completion -``` - - -Coerce a handler's return value into a wire ``Completion``. - -A returned ``str`` is rejected: it is almost always a mistake (the value -would iterate into one-character candidates), so it raises rather than -silently producing surprising output. - -The MCP contract caps a completion at 100 values, so a longer result is -truncated to the first 100 with ``has_more`` set — a handler that returns -thousands of matches emits a conforming response rather than an oversized -one that strict clients reject. - diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx deleted file mode 100644 index a9d766b6b..000000000 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ /dev/null @@ -1,711 +0,0 @@ ---- -title: context -sidebarTitle: context ---- - -# `fastmcp.server.context` - -## Functions - -### `set_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L97" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_transport(transport: TransportType) -> Token[TransportType | None] -``` - - -Set the current transport type. Returns token for reset. - - -### `reset_transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L104" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -reset_transport(token: Token[TransportType | None]) -> None -``` - - -Reset transport to previous value. - - -### `set_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L134" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_context(context: Context) -> Generator[Context, None, None] -``` - -## Classes - -### `LogData` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Data object for passing log arguments to client-side handlers. - -This provides an interface to match the Python standard library logging, -for compatibility with structured logging. - - -### `Context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L143" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Context object providing access to MCP capabilities. - -This provides a cleaner interface to MCP's RequestContext functionality. -It gets injected into tool and resource functions that request it via type hints. - -To use context in a tool function, add a parameter with the Context type annotation: - -```python -@server.tool -async def my_tool(x: int, ctx: Context) -> str: - # Log messages to the client - await ctx.info(f"Processing {x}") - await ctx.debug("Debug info") - await ctx.warning("Warning message") - await ctx.error("Error message") - - # Report progress - await ctx.report_progress(50, 100, "Processing") - - # Access resources - data = await ctx.read_resource("resource://data") - - # Get request info - request_id = ctx.request_id - client_id = ctx.client_id - - # Manage state across the session (persists across requests) - await ctx.set_state("key", "value") - value = await ctx.get_state("key") - - # Store non-serializable values for the current request only - await ctx.set_state("client", http_client, serializable=False) - - return str(x) -``` - -State Management: -Context provides session-scoped state that persists across requests within -the same MCP session. State is automatically keyed by session, ensuring -isolation between different clients. - -State set during `on_initialize` middleware will persist to subsequent tool -calls when using the same session object (STDIO, SSE, single-server HTTP). -For distributed/serverless HTTP deployments where different machines handle -the init and tool calls, state is isolated by the mcp-session-id header. - -The context parameter name can be anything as long as it's annotated with Context. -The context is optional - tools that don't need it can omit the parameter. - - -**Methods:** - -#### `is_background_task` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L224" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_background_task(self) -> bool -``` - -True when this context is running in a background task (Docket worker). - -When True, certain operations like elicit() will use task-aware -implementations that can pause the task and wait for client input. - - -#### `task_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L242" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -task_id(self) -> str | None -``` - -Get the background task ID if running in a background task. - -Returns None if not running in a background task context. - - -#### `origin_request_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L250" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -origin_request_id(self) -> str | None -``` - -Get the request ID that originated this execution, if available. - -In foreground request mode, this is the current request_id. -In background task mode, this is the request_id captured when the task -was submitted, if one was available. - - -#### `fastmcp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L262" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -fastmcp(self) -> FastMCP -``` - -Get the FastMCP instance. - - -#### `request_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L312" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -request_context(self) -> FastMCPRequestContext | None -``` - -Access to the underlying request context. - -Returns None when the MCP session has not been established yet. -Returns the FastMCPRequestContext wrapper once the MCP session is available. - -For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies, -which works whether or not the MCP session is available. - -Example in middleware: -```python -async def on_request(self, context, call_next): - ctx = context.fastmcp_context - if ctx.request_context: - # MCP session available - can access session_id, request_id, etc. - session_id = ctx.session_id - else: - # MCP session not available yet - use HTTP helpers - from fastmcp.server.dependencies import get_http_request - request = get_http_request() - return await call_next(context) -``` - - -#### `client_extension_settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L337" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -client_extension_settings(self, identifier: str) -> dict[str, Any] | None -``` - -This request's per-request opt-in settings for an MCP extension. - -SEP-2133 extensions negotiate per request: the client repeats its -extension capabilities in each request's ``_meta`` under -``io.modelcontextprotocol/clientCapabilities`` → ``extensions`` → -``identifier``. Returns the declared settings dict (possibly empty) when -the extension was opted in for this request, or ``None`` when it was -not (or there is no active request). This bridges an extension's -``tools/call`` interceptor — which receives a FastMCP ``Context`` — to -the request's declared client capabilities. - - -#### `input_responses` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L378" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -input_responses(self) -> mcp_types.InputResponses | None -``` - -Client responses to a prior `InputRequiredResult.input_requests`. - -The multi-round-trip guard channel (SEP-2322). A guard tool inspects -this to decide what to do on each round: `None` on the initial round -(nothing has been asked yet, or the client retried without responses), -so the tool returns an `InputRequiredResult` to ask; present on a later -round, so the tool reads the answers and proceeds. It is a mapping whose -keys match the `input_requests` map the tool minted; each value is the -client's result for that request (an `ElicitResult`, `CreateMessageResult`, -or `ListRootsResult`). - -In a background task there is no wire request, so this falls back to the -responses the in-task guard loop delivered (see the tasks extension). - - -#### `request_state` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L399" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -request_state(self) -> str | None -``` - -Opaque state echoed from a prior `InputRequiredResult.request_state`. - -The multi-round-trip guard channel (SEP-2322): whatever a tool put in -`InputRequiredResult.request_state` on an earlier round is handed back -here (as plaintext — the framework seals it on the wire and unseals it -before the tool runs, so tampering is rejected before this is read). -`None` on the initial round. Use it to carry a small amount of computed -state across rounds without re-deriving it. - -In a background task there is no wire request, so this falls back to the -state the in-task guard loop re-injected (see the tasks extension). - - -#### `lifespan_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L418" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -lifespan_context(self) -> dict[str, Any] -``` - -Access the server's lifespan context. - -Returns the context dict yielded by *this* server's lifespan function. -For a mounted child this is the child's own lifespan, not the parent's -— the MCP session always belongs to the parent, so reading from the -request context would return the parent's. We read directly from the -server's cached lifespan result instead, which is set by the -per-server ``_lifespan_manager`` regardless of mount position. - -Returns an empty dict if no lifespan was configured. - -Example: -```python -@server.tool -def my_tool(ctx: Context) -> str: - db = ctx.lifespan_context.get("db") - if db: - return db.query("SELECT 1") - return "No database connection" -``` - - -#### `report_progress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L453" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None -``` - -Report progress for the current operation. - -Works in both foreground (MCP progress notifications) and background -(Docket task execution) contexts. - -**Args:** -- `progress`: Current progress value e.g. 24 -- `total`: Optional total value e.g. 100 -- `message`: Optional status message describing current progress - - -#### `list_resources` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L552" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_resources(self) -> list[SDKResource] -``` - -List all available resources from the server. - -**Returns:** -- List of Resource objects available on the server - - -#### `list_prompts` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L563" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_prompts(self) -> list[SDKPrompt] -``` - -List all available prompts from the server. - -**Returns:** -- List of Prompt objects available on the server - - -#### `get_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L574" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult -``` - -Get a prompt by name with optional arguments. - -**Args:** -- `name`: The name of the prompt to get -- `arguments`: Optional arguments to pass to the prompt - -**Returns:** -- The prompt result - - -#### `read_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L593" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -read_resource(self, uri: str | AnyUrl) -> ResourceResult -``` - -Read a resource by URI. - -**Args:** -- `uri`: Resource URI to read - -**Returns:** -- ResourceResult with contents - - -#### `log` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L609" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None -``` - -Send a log message to the client. - -Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. - -**Args:** -- `message`: Log message -- `level`: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical", -"alert", or "emergency". Default is "info". -- `logger_name`: Optional logger name -- `extra`: Optional mapping for additional arguments - - -#### `transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L650" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -transport(self) -> TransportType | None -``` - -Get the current transport type. - -Returns the transport type used to run this server: "stdio", "sse", -or "streamable-http". Returns None if called outside of a server context. - - -#### `client_supports_extension` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L658" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -client_supports_extension(self, extension_id: str) -> bool -``` - -Check whether the connected client supports a given MCP extension. - -Inspects the ``extensions`` extra field on ``ClientCapabilities`` -sent by the client during initialization. - -Reads the client's advertised capabilities from the session, which is -available in request mode and in background-task mode (where the -snapshot session preserves the client's initialize params). Returns -``False`` when no session is available (e.g., a distributed worker with -no live session, or outside any context) or when the client did not -advertise the extension. - -Example:: - - from fastmcp.apps.config import UI_EXTENSION_ID - - @mcp.tool - async def my_tool(ctx: Context) -> str: - if ctx.client_supports_extension(UI_EXTENSION_ID): - return "UI-capable client" - return "text-only client" - - -#### `client_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L688" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -client_id(self) -> str | None -``` - -Get the client ID if available. - - -#### `request_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L696" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -request_id(self) -> str -``` - -Get the unique ID for this request. - -Raises RuntimeError if MCP request context is not available. - - -#### `session_id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L709" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -session_id(self) -> str -``` - -Get the MCP session ID for ALL transports. - -Returns the session ID that can be used as a key for session-based -data storage (e.g., Redis) to share data between tool calls within -the same client session. - -**Returns:** -- The session ID for StreamableHTTP transports, or a generated ID -- for other transports. - - -#### `session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L794" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -session(self) -> ServerSession -``` - -Access to the underlying session for advanced usage. - -In request mode: Returns the session from the active request context. -In background task mode: Returns the session stored at Context creation. - -Raises RuntimeError if no session is available. - - -#### `debug` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L820" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None -``` - -Send a `DEBUG`-level message to the connected MCP Client. - -Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. - - -#### `info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L836" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None -``` - -Send a `INFO`-level message to the connected MCP Client. - -Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. - - -#### `warning` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L852" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None -``` - -Send a `WARNING`-level message to the connected MCP Client. - -Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. - - -#### `error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L868" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None -``` - -Send a `ERROR`-level message to the connected MCP Client. - -Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. - - -#### `send_notification` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L884" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -send_notification(self, notification: mcp_types.ServerNotification) -> None -``` - -Send a notification to the client immediately. - -**Args:** -- `notification`: An MCP notification instance (e.g., ToolListChangedNotification()) - - -#### `close_sse_stream` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L904" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -close_sse_stream(self) -> None -``` - -Close the current response stream to trigger client reconnection. - -When using StreamableHTTP transport with an EventStore configured, this -method gracefully closes the HTTP connection for the current request. -The client will automatically reconnect (after `retry_interval` milliseconds) -and resume receiving events from where it left off via the EventStore. - -This is useful for long-running operations to avoid load balancer timeouts. -Instead of holding a connection open for minutes, you can periodically close -and let the client reconnect. - - -#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L958" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation -``` - -The accepted elicitation will contain the response data - - -#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L969" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation -``` - -When response_type is a list of strings, the accepted elicitation will -contain the selected string response - - -#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L981" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation -``` - -When response_type is a dict mapping keys to title dicts, the accepted -elicitation will contain the selected key - - -#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L993" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation -``` - -When response_type is a list containing a list of strings (multi-select), -the accepted elicitation will contain a list of selected strings - - -#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1005" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation -``` - -When response_type is a list containing a dict mapping keys to title dicts -(multi-select with titles), the accepted elicitation will contain a list of -selected keys - - -#### `elicit` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1017" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]]) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation -``` - -Send an elicitation request to the client and await the response. - -Call this method at any time to request additional information from -the user through the client. The client must support elicitation, -or the request will error. - -Note that the MCP protocol only supports simple object schemas with -primitive types. You can provide a dataclass, TypedDict, or BaseModel to -comply. If you provide a primitive type, an object schema with a single -"value" field will be generated for the MCP interaction and -automatically deconstructed into the primitive type upon response. - -``response_type`` is required. Pass ``bool`` when all you need is a -confirmation; an empty schema leaves some clients rendering an empty, -non-functional form. - -**Args:** -- `message`: A human-readable message explaining what information is needed -- `response_type`: The type of the response, which should be a primitive -type or dataclass or BaseModel. If it is a primitive type, an -object schema with a single "value" field will be generated. -- `response_title`: Optional label to display for the wrapped ``value`` -field when ``response_type`` is a scalar, Literal, Enum, or one -of the dict/list shorthand forms. Overrides the auto-generated -"Value" label. Raises ``TypeError`` if passed with a BaseModel, -dataclass, or ``None`` response type (use ``Field(title=...)`` -on the model instead). -- `response_description`: Optional description to attach to the wrapped -``value`` field. Same scope rules as ``response_title``. - - -#### `set_state` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_state(self, key: str, value: Any) -> None -``` - -Set a value in the state store. - -By default, values are stored in the session-scoped state store and -persist across requests within the same MCP session. Values must be -JSON-serializable (dicts, lists, strings, numbers, etc.). - -For non-serializable values (e.g., HTTP clients, database connections), -pass ``serializable=False``. These values are stored in a request-scoped -dict and only live for the current MCP request (tool call, resource -read, or prompt render). They will not be available in subsequent -requests. - -The key is automatically prefixed with the session identifier. - - -#### `get_state` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1164" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_state(self, key: str) -> Any -``` - -Get a value from the state store. - -Checks request-scoped state first (set with ``serializable=False``), -then falls back to the session-scoped state store. - -Returns None if the key is not found. - - -#### `delete_state` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1178" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -delete_state(self, key: str) -> None -``` - -Delete a value from the state store. - -Removes from both request-scoped and session-scoped stores. - - -#### `enable_components` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1199" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -enable_components(self) -> None -``` - -Enable components matching criteria for this session only. - -Session rules override global transforms. Rules accumulate - each call -adds a new rule to the session. Later marks override earlier ones -(Visibility transform semantics). - -Sends notifications to this session only: ToolListChangedNotification, -ResourceListChangedNotification, and PromptListChangedNotification. - -**Args:** -- `names`: Component names or URIs to match. -- `keys`: Component keys to match (e.g., {"tool\:my_tool@v1"}). -- `version`: Component version spec to match. -- `tags`: Tags to match (component must have at least one). -- `components`: Component types to match (e.g., {"tool", "prompt"}). -- `match_all`: If True, matches all components regardless of other criteria. - - -#### `disable_components` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1237" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -disable_components(self) -> None -``` - -Disable components matching criteria for this session only. - -Session rules override global transforms. Rules accumulate - each call -adds a new rule to the session. Later marks override earlier ones -(Visibility transform semantics). - -Sends notifications to this session only: ToolListChangedNotification, -ResourceListChangedNotification, and PromptListChangedNotification. - -**Args:** -- `names`: Component names or URIs to match. -- `keys`: Component keys to match (e.g., {"tool\:my_tool@v1"}). -- `version`: Component version spec to match. -- `tags`: Tags to match (component must have at least one). -- `components`: Component types to match (e.g., {"tool", "prompt"}). -- `match_all`: If True, matches all components regardless of other criteria. - - -#### `reset_visibility` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/context.py#L1275" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -reset_visibility(self) -> None -``` - -Clear all session visibility rules. - -Use this to reset session visibility back to global defaults. - -Sends notifications to this session only: ToolListChangedNotification, -ResourceListChangedNotification, and PromptListChangedNotification. - diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx deleted file mode 100644 index 1ab291fb1..000000000 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ /dev/null @@ -1,614 +0,0 @@ ---- -title: dependencies -sidebarTitle: dependencies ---- - -# `fastmcp.server.dependencies` - - -Dependency injection for FastMCP. - -DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket -using the uncalled-for DI engine. The docket-specific dependencies -(``CurrentDocket``, ``CurrentWorker``) and background task execution live in the -``fastmcp-tasks`` package. - - -## Functions - -### `bind_request_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L104" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -bind_request_context(ctx: ServerRequestContext) -> Generator[FastMCPRequestContext, None, None] -``` - - -Bind a ``FastMCPRequestContext`` for the duration of a handler. - -Constructs the wrapper from the SDK's per-request context and sets/resets -the ``fastmcp_request_ctx`` ContextVar. Every request adapter and the -initialize middleware enters this so ``Context`` and dependency helpers can -read the active request from the ContextVar. - - -### `extract_version_spec` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L131" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -extract_version_spec(meta: dict[str, Any] | None) -> str | None -``` - - -Extract the FastMCP component version from a lifted ``_meta`` block. - - -### `set_background_context_factory` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_background_context_factory(factory: Callable[[], Awaitable[Context | None]] | None) -> None -``` - - -Install (or clear) the background-task ``Context`` factory. - -The factory returns an already-entered ``Context`` (so ``_current_context`` -is set for cleanup) when called inside a worker, or ``None`` when there is -no task context. Passing ``None`` restores core's no-worker-fallback -behavior. - - -### `set_worker_server_resolver` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L207" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_worker_server_resolver(resolver: Callable[[], FastMCP | None] | None) -> None -``` - - -Install (or clear) the worker-server resolver used by ``get_server()``. - - -### `is_docket_available` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L244" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_docket_available() -> bool -``` - - -Check if a compatible pydocket (>= 0.19.0) is installed and importable. - -Three things have to be true for fastmcp's task features to work: - 1. pydocket distribution metadata is discoverable - 2. its version is at least ``_MIN_DOCKET_VERSION`` (older versions are - missing symbols like ``docket.dependencies.current_execution``, - which fastmcp imports on the request hot path) - 3. the package actually imports — guards against broken/partial - installs where metadata exists but ``import docket`` blows up - -Any of those failing means we treat docket as unavailable and fall back -to the no-tasks code paths instead of crashing deep inside a request. - - -### `transform_context_annotations` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L276" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any] -``` - - -Transform injected-by-type params into Dependency-defaulted params. - -Transforms ALL params typed as Context (into ``= CurrentContext()``) and as -UserSession (into ``= CurrentSession()``) to use Docket's DI system, unless -they already have a Dependency-based default. - -This unifies the legacy type annotation DI with Docket's Depends() system, -allowing both patterns to work through a single resolution path. - -Note: Only POSITIONAL_OR_KEYWORD parameters are reordered (params with defaults -after those without). KEYWORD_ONLY parameters keep their position since Python -allows them to have defaults in any order. - -**Args:** -- `fn`: Function to transform - -**Returns:** -- Function with modified signature (same function object, updated __signature__) - - -### `get_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L442" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_context() -> Context -``` - - -Get the current FastMCP Context instance directly. - - -### `get_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L452" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_server() -> FastMCP -``` - - -Get the current FastMCP server instance directly. - -In a background-task worker the tasks extension's resolver is consulted -first, so a mounted-child task resolves to the child server rather than the -root that started the worker (#3571). - -**Returns:** -- The active FastMCP server - -**Raises:** -- `RuntimeError`: If no server in context - - -### `get_session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L480" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_session(session_id: str) -> Session -``` - - -Resolve and validate a `Session` for an explicit `session_id`. - -Pair with a `session_id: SessionId` tool argument (the agent obtains an id -from `create_session` and passes it back). For a single per-user bucket with -nothing for the agent to pass, inject `session: UserSession` instead. - -State is keyed by `(principal, session_id)`: the authenticated principal is -the isolation wall and `session_id` organizes sessions within it. The id must -have been minted by `create_session` under the current principal; an id that -was never created, or created under a different principal, raises -`InvalidSession` rather than resolving to a fresh empty bucket (the specific -reason is logged at debug level, never returned to the caller). - -Like `get_server()`, this resolves through the task-aware server, so it needs -no foreground context — it works from a `task=True` tool's Docket worker as -well as a normal request. - - -### `get_http_request` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L515" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_http_request() -> Request -``` - - -Get the current HTTP request. - -Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. - - -### `get_http_headers` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L536" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_http_headers(include_all: bool = False, include: set[str] | None = None) -> dict[str, str] -``` - - -Extract headers from the current HTTP request if available. - -Never raises an exception, even if there is no active HTTP request (in which case -an empty dict is returned). - -By default, strips problematic headers like `content-length` and `authorization` -that cause issues if forwarded to downstream services. If `include_all` is True, -all headers are returned. - -The `include` parameter allows specific headers to be included even if they would -normally be excluded. This is useful for proxy transports that need to forward -authorization headers to upstream MCP servers. - - -### `get_access_token` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L600" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_access_token() -> AccessToken | None -``` - - -Get the FastMCP access token from the current context. - -This function first tries to get the token from the current HTTP request's scope, -which is more reliable for long-lived connections where the SDK's auth_context_var -may become stale after token refresh. Falls back to the SDK's context var if no -request is available. - -**Returns:** -- The access token if an authenticated user is available, None otherwise. - - -### `without_injected_parameters` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L659" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] -``` - - -Create a wrapper function without injected parameters. - -Returns a wrapper that excludes Context and Docket dependency parameters, -making it safe to use with Pydantic TypeAdapter for schema generation and -validation. The wrapper internally handles all dependency resolution and -Context injection when called. - -Handles: -- Legacy Context injection (always works) -- Depends() injection (always works - uses docket or vendored DI engine) - -**Args:** -- `fn`: Original function with Context and/or dependencies -- `run_in_thread`: For sync ``fn``, whether to dispatch the call to a worker -thread after resolving dependencies. Defaults to True. Set to False -to call ``fn`` inline on the event loop thread — required for -thread-affinity libraries (e.g. Windows COM). Ignored for async fns. - -**Returns:** -- Async wrapper function without injected parameters - - -### `resolve_dependencies` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L820" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] -``` - - -Resolve dependencies for a FastMCP function. - -This function: -1. Filters out any dependency parameter names from user arguments (security) -2. Resolves Depends() parameters via the DI system - -The filtering prevents external callers from overriding injected parameters by -providing values for dependency parameter names. This is a security feature. - -Note: Context injection is handled via transform_context_annotations() which -converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration -time, so all injection goes through the unified DI system. - -**Args:** -- `fn`: The function to resolve dependencies for -- `arguments`: User arguments (may contain keys that match dependency names, - which will be filtered out) - - -### `CurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L945" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -CurrentContext() -> Context -``` - - -Get the current FastMCP Context instance. - -This dependency provides access to the active FastMCP Context for the -current MCP operation (tool/resource/prompt call). - -**Returns:** -- A dependency that resolves to the active Context instance - -**Raises:** -- `RuntimeError`: If no active context found (during resolution) - - -### `OptionalCurrentContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L970" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -OptionalCurrentContext() -> Context | None -``` - - -Get the current FastMCP Context, or None when no context is active. - - -### `CurrentFastMCP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L990" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -CurrentFastMCP() -> FastMCP -``` - - -Get the current FastMCP server instance. - -This dependency provides access to the active FastMCP server. - -**Returns:** -- A dependency that resolves to the active FastMCP server - -**Raises:** -- `RuntimeError`: If no server in context (during resolution) - - -### `CurrentRequest` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1030" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -CurrentRequest() -> Request -``` - - -Get the current HTTP request. - -This dependency provides access to the Starlette Request object for the -current HTTP request. Only available when running over HTTP transports -(SSE or Streamable HTTP). - -**Returns:** -- A dependency that resolves to the active Starlette Request - -**Raises:** -- `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) - - -### `CurrentHeaders` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1071" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -CurrentHeaders() -> dict[str, str] -``` - - -Get the current HTTP request headers. - -This dependency provides access to the HTTP headers for the current request, -including the authorization header. Returns an empty dictionary when no HTTP -request is available, making it safe to use in code that might run over any -transport. - -**Returns:** -- A dependency that resolves to a dictionary of header name -> value - - -### `CurrentAccessToken` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1289" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -CurrentAccessToken() -> AccessToken -``` - - -Get the current access token for the authenticated user. - -This dependency provides access to the AccessToken for the current -authenticated request. Raises an error if no authentication is present. - -**Returns:** -- A dependency that resolves to the active AccessToken - -**Raises:** -- `RuntimeError`: If no authenticated user (use get_access_token() for optional) - - -### `TokenClaim` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1346" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -TokenClaim(name: str) -> str -``` - - -Get a specific claim from the access token. - -This dependency extracts a single claim value from the current access token. -It's useful for getting user identifiers, roles, or other token claims -without needing the full token object. - -**Args:** -- `name`: The name of the claim to extract (e.g., "oid", "sub", "email") - -**Returns:** -- A dependency that resolves to the claim value as a string - -**Raises:** -- `RuntimeError`: If no access token is available or claim is missing - - -## Classes - -### `FastMCPRequestContext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L55" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -FastMCP-owned wrapper around the SDK's per-request context. - -The SDK v2 runner hands each handler a fresh ``ServerRequestContext`` as an -argument rather than exposing it through a ContextVar. FastMCP owns this -ContextVar (``fastmcp_request_ctx``) and each request adapter binds a -``FastMCPRequestContext`` at the top of the handler (and the initialize -middleware binds it too). - -A wrapper rather than the raw context because the SDK's -``ServerRequestContext.meta`` is a bare ``RequestParamsMeta`` TypedDict that -only carries ``progress_token`` — it does not carry ``_meta.fastmcp`` or the -distributed-trace parent. Those live in the raw params dict under ``_meta``, -which this wrapper lifts once so downstream consumers have a stable surface. - - -### `ProgressLike` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1099" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Protocol for progress tracking interface. - -Defines the common interface between InMemoryProgress (server context) -and Docket's Progress (worker context). - - -**Methods:** - -#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1107" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -current(self) -> int | None -``` - -Current progress value. - - -#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1112" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -total(self) -> int -``` - -Total/target progress value. - - -#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1117" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -message(self) -> str | None -``` - -Current progress message. - - -#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1121" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_total(self, total: int) -> None -``` - -Set the total/target value for progress tracking. - - -#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -increment(self, amount: int = 1) -> None -``` - -Atomically increment the current progress value. - - -#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1129" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_message(self, message: str | None) -> None -``` - -Update the progress status message. - - -### `InMemoryProgress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1134" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -In-memory progress tracker for immediate tool execution. - -Provides the same interface as Docket's Progress but stores state in memory -instead of Redis. Useful for testing and immediate execution where -progress doesn't need to be observable across processes. - - -**Methods:** - -#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1159" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -current(self) -> int | None -``` - -#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1163" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -total(self) -> int -``` - -#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1167" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -message(self) -> str | None -``` - -#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_total(self, total: int) -> None -``` - -Set the total/target value for progress tracking. - - -#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1176" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -increment(self, amount: int = 1) -> None -``` - -Atomically increment the current progress value. - - -#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_message(self, message: str | None) -> None -``` - -Update the progress status message. - - -### `Progress` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1190" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Progress dependency that works in both server and worker contexts. - -In a Docket worker, delegates to the execution's Redis-backed progress -(observable across processes). Otherwise, uses in-memory tracking. - -The shared default instance acts as a stateless factory — ``__aenter__`` -creates a fresh ``Progress`` per invocation so concurrent tasks never -share mutable state. - - -**Methods:** - -#### `current` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1231" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -current(self) -> int | None -``` - -Current progress value. - - -#### `total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1237" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -total(self) -> int -``` - -Total/target progress value. - - -#### `message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1243" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -message(self) -> str | None -``` - -Current progress message. - - -#### `set_total` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1248" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_total(self, total: int) -> None -``` - -Set the total/target value for progress tracking. - - -#### `increment` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1253" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -increment(self, amount: int = 1) -> None -``` - -Atomically increment the current progress value. - - -#### `set_message` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/dependencies.py#L1258" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_message(self, message: str | None) -> None -``` - -Update the progress status message. - diff --git a/docs/python-sdk/fastmcp-server-elicitation.mdx b/docs/python-sdk/fastmcp-server-elicitation.mdx deleted file mode 100644 index 824ab59e9..000000000 --- a/docs/python-sdk/fastmcp-server-elicitation.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: elicitation -sidebarTitle: elicitation ---- - -# `fastmcp.server.elicitation` - -## Functions - -### `parse_elicit_response_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L143" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -parse_elicit_response_type(response_type: Any, response_title: str | None = None, response_description: str | None = None) -> ElicitConfig -``` - - -Parse response_type into schema and handling configuration. - -A response type is required; ``None`` raises ``TypeError``. Supports -multiple syntaxes: -- dict: `{"low": {"title": "..."}}` -> single-select titled enum -- list patterns: - - `[["a", "b"]]` -> multi-select untitled - - `[{"low": {...}}]` -> multi-select titled - - `["a", "b"]` -> single-select untitled -- `list\[X]` type annotation: multi-select with type -- Scalar types (bool, int, float, str, Literal, Enum): single value -- Other types (dataclass, BaseModel): use directly - -The ``response_title`` and ``response_description`` arguments customize the -label and description of the wrapped ``value`` property for the scalar/dict/list -shorthand forms. They are only valid when FastMCP is wrapping the response -type; passing them with a full BaseModel/dataclass raises ``TypeError``, -because in those cases the user already controls field metadata via -``Field(title=..., description=...)``. - - -### `handle_elicit_accept` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L310" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -handle_elicit_accept(config: ElicitConfig, content: Any) -> AcceptedElicitation[Any] -``` - - -Handle an accepted elicitation response. - -**Args:** -- `config`: The elicitation configuration from parse_elicit_response_type -- `content`: The response content from the client - -**Returns:** -- AcceptedElicitation with the extracted/validated data - - -### `get_elicitation_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L369" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_elicitation_schema(response_type: type[T]) -> dict[str, Any] -``` - - -Get the schema for an elicitation response. - -**Args:** -- `response_type`: The type of the response - - -### `validate_elicitation_json_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L395" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -validate_elicitation_json_schema(schema: dict[str, Any]) -> None -``` - - -Validate that a JSON schema follows MCP elicitation requirements. - -This ensures the schema is compatible with MCP elicitation requirements: -- Must be an object schema -- Must only contain primitive field types (string, number, integer, boolean) -- Must be flat (no nested objects or arrays of objects) -- Allows const fields (for Literal types) and enum fields (for Enum types) -- Only primitive types and their nullable variants are allowed - -**Args:** -- `schema`: The JSON schema to validate - -**Raises:** -- `TypeError`: If the schema doesn't meet MCP elicitation requirements - - -## Classes - -### `ElicitationJsonSchema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Custom JSON schema generator for MCP elicitation that always inlines enums. - -MCP elicitation requires inline enum schemas without $ref/$defs references. -This generator ensures enums are always generated inline for compatibility. -Optionally adds enumNames for better UI display when available. - - -**Methods:** - -#### `generate_inner` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L44" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue -``` - -Override to prevent ref generation for enums and handle list schemas. - - -#### `list_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L57" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_schema(self, schema: core_schema.ListSchema) -> JsonSchemaValue -``` - -Generate schema for list types, detecting enum items for multi-select. - - -#### `enum_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L94" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue -``` - -Generate inline enum schema. - -Always generates enum pattern: `{"enum": [value, ...]}` -Titled enums are handled separately via dict-based syntax in ctx.elicit(). - - -### `AcceptedElicitation` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L105" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Result when user accepts the elicitation. - - -### `ScalarElicitationType` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L113" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -### `ElicitConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/elicitation.py#L118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Configuration for an elicitation request. - -**Attributes:** -- `schema`: The JSON schema to send to the client -- `response_type`: The type to validate responses with (None for raw schemas) -- `is_raw`: True if schema was built directly (extract "value" from response) - diff --git a/docs/python-sdk/fastmcp-server-event_store.mdx b/docs/python-sdk/fastmcp-server-event_store.mdx deleted file mode 100644 index 39a2ba77b..000000000 --- a/docs/python-sdk/fastmcp-server-event_store.mdx +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: event_store -sidebarTitle: event_store ---- - -# `fastmcp.server.event_store` - - -EventStore implementation backed by AsyncKeyValue. - -This module provides an EventStore implementation that enables SSE polling/resumability -for Streamable HTTP transports. Events are stored using the key_value package's -AsyncKeyValue protocol, allowing users to configure any compatible backend -(in-memory, Redis, etc.) following the same pattern as ResponseCachingMiddleware. - - -## Classes - -### `EventEntry` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Stored event entry. - - -### `StreamEventList` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -List of event IDs for a stream. - - -### `EventStore` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L52" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -EventStore implementation backed by AsyncKeyValue. - -Enables SSE polling/resumability by storing events that can be replayed -when clients reconnect. Works with any AsyncKeyValue backend (memory, Redis, etc.) -following the same pattern as ResponseCachingMiddleware and OAuthProxy. - -**Args:** -- `storage`: AsyncKeyValue backend. Defaults to MemoryStore. -- `max_events_per_stream`: Maximum events to retain per stream. Default 100. -- `ttl`: Event TTL in seconds. Default 3600 (1 hour). Set to None for no expiration. - - -**Methods:** - -#### `store_event` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L120" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId -``` - -Store an event and return its ID. - -**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 - - -#### `replay_events_after` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/event_store.py#L166" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None -``` - -Replay 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, or None if the event ID was not found - diff --git a/docs/python-sdk/fastmcp-server-extensions.mdx b/docs/python-sdk/fastmcp-server-extensions.mdx deleted file mode 100644 index 3c8c2f62f..000000000 --- a/docs/python-sdk/fastmcp-server-extensions.mdx +++ /dev/null @@ -1,194 +0,0 @@ ---- -title: extensions -sidebarTitle: extensions ---- - -# `fastmcp.server.extensions` - - -FastMCP-native server extension API (SEP-2133). - -An MCP extension is an opt-in, capability-negotiated bundle of protocol -behaviour identified by a reverse-DNS string (e.g. `io.modelcontextprotocol/tasks`). -Unlike the SDK's `mcp.server.extension.Extension`, a FastMCP `ServerExtension` -is bound to its `FastMCP` instance at registration, so its request handlers and -its `tools/call` interceptor can reach the component registry, `Context`, and -auth scope that the SDK's model withholds. - -An extension contributes any subset of four things: - -- **A negotiated capability.** `settings()` is spliced into - `ServerCapabilities.extensions[identifier]` (see `LowLevelServer.get_capabilities`). -- **New request methods.** `methods()` returns `MethodBinding`s, each wired onto - the low-level server via `add_request_handler` when the extension is registered. -- **A `tools/call` interceptor.** `intercept_tool_call()` is the last gate before - a tool body runs — it composes *after* the FastMCP middleware chain and *before* - component execution, so it can observe, short-circuit, or pass a call through. -- **A lifespan.** `lifespan()` is entered with the server's lifespan and exited on - shutdown — the hook the SDK's `Extension` lacks, needed to start backends/workers. - -The base class follows the SDK's httpx-style shape: every contribution method has -a default, so a subclass overrides only what it needs. - - -## Functions - -### `read_client_extension_settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L235" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -read_client_extension_settings(ctx: ServerRequestContext[Any, Any], identifier: str) -> dict[str, Any] | None -``` - - -Read a client's per-request extension opt-in from the request `_meta`. - -SEP-2133 extensions negotiate per request: the client repeats its extension -capabilities in each request's `_meta` under -`io.modelcontextprotocol/clientCapabilities` → `extensions` → `identifier`. -Returns the declared settings dict (possibly empty) when the extension was -opted in for this request, or `None` when it was not. - - -### `build_method_handler` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L249" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -build_method_handler(binding: MethodBinding) -> ExtensionRequestHandler -``` - - -Wrap a `MethodBinding` into a low-level request handler. - -The adapter enforces `protocol_versions` gating (rejecting other versions as -`METHOD_NOT_FOUND`, since `add_request_handler` registers unconditionally) -and binds the FastMCP request context so the handler can use `get_context()`, -auth, and other request-scoped dependencies. - - -### `wrap_tool_call_interceptor` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L278" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -wrap_tool_call_interceptor(extension: ServerExtension, call_next: Callable[[Any], Awaitable[Any]]) -> Callable[[Any], Awaitable[Any]] -``` - - -Fold one extension's `intercept_tool_call` around a middleware `call_next`. - -The returned wrapper is a FastMCP `CallNext`: it hands the extension the -validated `tools/call` params, the FastMCP `Context`, and a zero-arg -continuation that runs the rest of the chain and, finally, the tool body. - - -## Classes - -### `MethodBinding` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L77" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -A new request method an extension serves, e.g. `tasks/get`. - -`params_type` validates incoming params before `handler` runs; it should -subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`, -when set, restricts the method to those wire versions — a request at any -other version is rejected as `METHOD_NOT_FOUND`, mirroring the spec's -`(method, version)` boundary. `None` (the default) admits every version. - -Extension methods are additive: `method` must not name a spec-defined -request method (`tools/call`, `completion/complete`, ...). Binding one would -silently shadow the server's own handler. Both constraints are enforced at -construction. - - -### `ServerExtension` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L111" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Base class for an opt-in FastMCP server extension (SEP-2133). - -Subclass, set `identifier`, and override the contribution methods that -apply. Every method has a default, so a minimal extension overrides only -`identifier` and one contribution. `identifier` is validated at -subclass-definition time when set as a class attribute, and again at -registration (which covers per-instance identifiers assigned in `__init__`). - -Register an instance with `FastMCP.add_extension(...)`, which binds the -extension to the server so `self.server`, `intercept_tool_call`, and method -handlers can reach FastMCP-level constructs. - - -**Methods:** - -#### `server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L149" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -server(self) -> FastMCP -``` - -The FastMCP server this extension is registered on. - -Handlers, interceptors, and lifespan code reach the component registry, -`Context`, and auth scope through here. Raises if the extension has not -been registered with `FastMCP.add_extension()`. - - -#### `settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L165" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -settings(self) -> dict[str, Any] -``` - -Per-extension settings advertised at `capabilities.extensions[identifier]`. - -An empty dict (the default) advertises the extension with no settings. - - -#### `methods` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L172" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -methods(self) -> Sequence[MethodBinding] -``` - -New request methods this extension serves (additive). - - -#### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L176" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -lifespan(self) -> AbstractAsyncContextManager[None] -``` - -A context manager entered with the server's lifespan, exited on shutdown. - -Default: a no-op. Override to start and stop resources an extension owns -(a task-queue backend and worker, say). Entered once per runtime tree, at -the root — a mounted child defers to the root, as the shared Docket does. - - -#### `intercept_tool_call` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -intercept_tool_call(self, params: CallToolRequestParams, context: Context, call_next: ToolCallContinuation) -> ToolCallOutcome -``` - -Wrap `tools/call`. Default: pass through unchanged. - -Runs after the FastMCP middleware chain and before the tool body, so it -is the last gate before execution. Override to observe the call, to -short-circuit (return a result without awaiting `call_next`), or to pass -it through (`return await call_next()`). `params` is the validated -`tools/call` params; `context` is the FastMCP `Context`, from which the -tool being called (`context.fastmcp.get_tool(params.name)`), auth scope, -and the server are reachable. Multiple extensions nest with the -first-registered outermost. - - -#### `client_settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/extensions.py#L204" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -client_settings(self, ctx: ServerRequestContext[Any, Any]) -> dict[str, Any] | None -``` - -This extension's per-request opt-in settings declared by the client. - -Reads the request's `_meta` client-capabilities block. Returns the -declared settings dict (possibly empty) when the client opted this -extension in for the request, or `None` when it did not. Convenience for -`read_client_extension_settings(ctx, self.identifier)`. - diff --git a/docs/python-sdk/fastmcp-server-http.mdx b/docs/python-sdk/fastmcp-server-http.mdx deleted file mode 100644 index 46db6c15a..000000000 --- a/docs/python-sdk/fastmcp-server-http.mdx +++ /dev/null @@ -1,144 +0,0 @@ ---- -title: http -sidebarTitle: http ---- - -# `fastmcp.server.http` - -## Functions - -### `set_http_request` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L355" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set_http_request(request: Request) -> Generator[Request, None, None] -``` - -### `create_base_app` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L388" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -create_base_app(routes: list[BaseRoute], middleware: list[Middleware], debug: bool = False, lifespan: Callable | None = None) -> StarletteWithLifespan -``` - - -Create a base Starlette app with common middleware and routes. - -**Args:** -- `routes`: List of routes to include in the app -- `middleware`: List of middleware to include in the app -- `debug`: Whether to enable debug mode -- `lifespan`: Optional lifespan manager for the app - -**Returns:** -- A Starlette application - - -### `create_sse_app` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L416" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -create_sse_app(server: FastMCP[LifespanResultT], message_path: str, sse_path: str, auth: AuthProvider | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan -``` - - -Return an instance of the SSE server app. - -**Args:** -- `server`: The FastMCP server instance -- `message_path`: Path for SSE messages -- `sse_path`: Path for SSE connections -- `auth`: Optional authentication provider (AuthProvider) -- `debug`: Whether to enable debug mode -- `routes`: Optional list of custom routes -- `middleware`: Optional list of middleware - -Returns: - A Starlette application with RequestContextMiddleware - - -### `create_streamable_http_app` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L545" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -create_streamable_http_app(server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, retry_interval: int | None = None, auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None, host_origin_protection: HostOriginProtection = False, allowed_hosts: Sequence[str] | None = None, allowed_origins: Sequence[str] | None = None, session_idle_timeout: float | None = None) -> StarletteWithLifespan -``` - - -Return an instance of the StreamableHTTP server app. - -**Args:** -- `server`: The FastMCP server instance -- `streamable_http_path`: Path for StreamableHTTP connections -- `event_store`: Optional event store for SSE polling/resumability -- `retry_interval`: Optional retry interval in milliseconds for SSE polling. -Controls how quickly clients should reconnect after server-initiated -disconnections. Requires event_store to be set. Defaults to SDK default. -- `auth`: Optional authentication provider (AuthProvider) -- `json_response`: Whether to use JSON response format -- `stateless_http`: Whether to use stateless mode (new transport per request) -- `debug`: Whether to enable debug mode -- `routes`: Optional list of custom routes -- `middleware`: Optional list of middleware -- `host_origin_protection`: Whether to validate Host and Origin headers -before requests reach the MCP endpoint. Defaults to False for -compatibility. "auto" protects localhost-bound servers and explicit -host/origin allowlists. -- `allowed_hosts`: Additional hostnames that may appear in the Host header. -- `allowed_origins`: Additional browser origins trusted by the request guard. -Configure CORS separately when browser JavaScript must read -cross-origin responses. -- `session_idle_timeout`: Maximum time in seconds a session may remain idle -before it is terminated. The deadline is pushed forward on every -request. When None, sessions never expire from inactivity. Not -supported in stateless mode. - -**Returns:** -- A Starlette application with StreamableHTTP support - - -## Classes - -### `FastMCPStreamableHTTPSessionManager` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L43" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Session manager that scopes resumability storage per transport session. - - -**Methods:** - -#### `event_store` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L68" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -event_store(self) -> EventStore | None -``` - -#### `event_store` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -event_store(self, event_store: EventStore | None) -> None -``` - -### `StreamableHTTPASGIApp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L80" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -ASGI application wrapper for Streamable HTTP server transport. - - -### `HostOriginGuardMiddleware` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L227" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Validate Host and Origin headers before requests reach MCP sessions. - - -### `StarletteWithLifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L348" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -**Methods:** - -#### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L350" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -lifespan(self) -> Lifespan[Starlette] -``` - -### `RequestContextMiddleware` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/http.py#L363" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Middleware that stores each request in a ContextVar and sets transport type. - diff --git a/docs/python-sdk/fastmcp-server-lifespan.mdx b/docs/python-sdk/fastmcp-server-lifespan.mdx deleted file mode 100644 index 091836304..000000000 --- a/docs/python-sdk/fastmcp-server-lifespan.mdx +++ /dev/null @@ -1,101 +0,0 @@ ---- -title: lifespan -sidebarTitle: lifespan ---- - -# `fastmcp.server.lifespan` - - -Composable lifespans for FastMCP servers. - -This module provides a `@lifespan` decorator for creating composable server lifespans -that can be combined using the `|` operator. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.lifespan import lifespan - - @lifespan - async def db_lifespan(server): - conn = await connect_db() - yield {"db": conn} - await conn.close() - - @lifespan - async def cache_lifespan(server): - cache = await connect_cache() - yield {"cache": cache} - await cache.close() - - mcp = FastMCP("server", lifespan=db_lifespan | cache_lifespan) - ``` - -To compose with existing `@asynccontextmanager` lifespans, wrap them explicitly: - - ```python - from contextlib import asynccontextmanager - from fastmcp.server.lifespan import lifespan, ContextManagerLifespan - - @asynccontextmanager - async def legacy_lifespan(server): - yield {"legacy": True} - - @lifespan - async def new_lifespan(server): - yield {"new": True} - - # Wrap the legacy lifespan explicitly - combined = ContextManagerLifespan(legacy_lifespan) | new_lifespan - ``` - - -## Functions - -### `lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/lifespan.py#L172" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -lifespan(fn: LifespanFn) -> Lifespan -``` - - -Decorator to create a composable lifespan. - -Use this decorator on an async generator function to make it composable -with other lifespans using the `|` operator. - -**Args:** -- `fn`: An async generator function that takes a FastMCP server and yields -a dict for the lifespan context. - -**Returns:** -- A composable Lifespan wrapper. - - -## Classes - -### `Lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/lifespan.py#L61" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Composable lifespan wrapper. - -Wraps an async generator function and enables composition via the `|` operator. -The wrapped function should yield a dict that becomes part of the lifespan context. - - -### `ContextManagerLifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/lifespan.py#L110" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Lifespan wrapper for already-wrapped context manager functions. - -Use this for functions already decorated with @asynccontextmanager. - - -### `ComposedLifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/lifespan.py#L137" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Two lifespans composed together. - -Enters the left lifespan first, then the right. Exits in reverse order. -Results are shallow-merged into a single dict. - diff --git a/docs/python-sdk/fastmcp-server-low_level.mdx b/docs/python-sdk/fastmcp-server-low_level.mdx deleted file mode 100644 index f515849e4..000000000 --- a/docs/python-sdk/fastmcp-server-low_level.mdx +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: low_level -sidebarTitle: low_level ---- - -# `fastmcp.server.low_level` - -## Functions - -### `client_supports_extension` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L111" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -client_supports_extension(session: ServerSession, extension_id: str) -> bool -``` - - -Check whether the connected client supports a given MCP extension. - -Inspects the ``extensions`` capability on ``ClientCapabilities`` sent by the -client during initialization. In v2 the client's initialize params are -reachable via ``session.client_params``. - -SDK v2 declares ``extensions`` as a real field on ``ClientCapabilities``, so -a client sending ``ClientCapabilities(extensions={...})`` populates the field -directly. We read that field first and fall back to ``model_extra`` only for -legacy-serialized clients that carried ``extensions`` as an extra key. - - -## Classes - -### `FastMCPServerMiddleware` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Root dispatch for the FastMCP middleware chain, in the SDK's middleware layer. - -v2 no longer lets FastMCP subclass ``ServerSession`` (the runner constructs -it per request), so the old ``MiddlewareServerSession._received_request`` -override is replaced by a ``ServerMiddleware`` — an ordinary entry in the -SDK's own middleware list. Sitting at the root of dispatch, this -is the single entry point through which *every* inbound message flows — -requests, notifications, cancellations, ``initialize``, and even malformed or -unroutable messages the SDK can still hand us. It binds the FastMCP -request-context ContextVar and re-applies the app-scoped ``SharedContext`` for -the whole chain, then runs the FastMCP ``Middleware`` chain so -``on_message`` / ``on_request`` / ``on_notification`` observe the message. - -Dispatch shapes: - -- Negotiation runs the *whole* FastMCP chain here: ``initialize`` dispatches - through ``on_initialize`` and ``server/discover`` through ``on_discover``. - Neither has an interior FastMCP handler adapter, and the SDK serializes both - results before returning through its middleware seam, so this root adapter - restores core results to typed models before FastMCP middleware observes them. -- The component methods (``tools/call``, ``tools/list``, ``resources/read``, - ...) still run their FastMCP chain *interior*, in the handler adapter, where - ``on_call_tool`` receives the typed component result and a tool exception - propagates through ``on_message``/``on_request`` exactly where the built-in - error/logging/timing middleware expect it. The root dispatch does not re-run the - chain for these — it only steps in when such a request fails *before* the - interior runs (malformed params, routing), so ``on_message`` still observes - the failure. -- Every other message — all notifications (including ``notifications/cancelled`` - and ``notifications/initialized``), ``ping``, ``logging/setLevel``, and any - unroutable/non-component request — has no interior FastMCP dispatch, so the - root dispatch runs the ``"outer"`` pass (``on_message`` plus - ``on_request``/``on_notification``) here, wrapping the real SDK dispatch. - This closes the long-standing gap where these messages were invisible to - FastMCP middleware. - - -### `LowLevelServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L455" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -**Methods:** - -#### `fastmcp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L507" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -fastmcp(self) -> FastMCP -``` - -Get the FastMCP instance. - - -#### `create_initialization_options` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L514" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -create_initialization_options(self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, extensions: dict[str, dict[str, Any]] | None = None) -> InitializationOptions -``` - -#### `get_capabilities` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/low_level.py#L529" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_capabilities(self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, extensions: dict[str, dict[str, Any]] | None = None) -> mcp_types.ServerCapabilities -``` - -Override to advertise registered extensions and the MCP Apps UI extension. - -``ServerCapabilities.extensions`` is a real declared field in v2, so we -update it directly. The -`FastMCP(experimental_capabilities=...)` merge also lives here rather -than in `create_initialization_options`: the modern `server/discover` -handler calls this directly, without going through -`create_initialization_options` at all, so merging there only reached -the handshake-era `initialize` response and silently dropped -constructor-configured experimental capabilities from `discover`. - diff --git a/docs/python-sdk/fastmcp-server-mixins.mdx b/docs/python-sdk/fastmcp-server-mixins.mdx deleted file mode 100644 index 9734da93c..000000000 --- a/docs/python-sdk/fastmcp-server-mixins.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: mixins -sidebarTitle: mixins ---- - -# `fastmcp.server.mixins` - - -Server mixins for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-providers.mdx b/docs/python-sdk/fastmcp-server-providers.mdx deleted file mode 100644 index c227ee1a0..000000000 --- a/docs/python-sdk/fastmcp-server-providers.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: providers -sidebarTitle: providers ---- - -# `fastmcp.server.providers` - - -Providers for dynamic MCP components. - -This module provides the `Provider` abstraction for providing tools, -resources, and prompts dynamically at runtime. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers import Provider - from fastmcp.tools import Tool - - class DatabaseProvider(Provider): - def __init__(self, db_url: str): - self.db = Database(db_url) - - async def _list_tools(self) -> list[Tool]: - rows = await self.db.fetch("SELECT * FROM tools") - return [self._make_tool(row) for row in rows] - - async def _get_tool(self, name: str) -> Tool | None: - row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name) - return self._make_tool(row) if row else None - - mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)]) - ``` - diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx deleted file mode 100644 index 12524e5b8..000000000 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ /dev/null @@ -1,891 +0,0 @@ ---- -title: server -sidebarTitle: server ---- - -# `fastmcp.server.server` - - -FastMCP - A more ergonomic interface for MCP servers. - -## Functions - -### `default_lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L237" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] -``` - - -Default lifespan context manager that does nothing. - -**Args:** -- `server`: The server instance this lifespan is managing - -**Returns:** -- An empty dictionary as the lifespan result. - - -### `create_proxy` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2501" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | SDKServer | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy -``` - - -Create a FastMCP proxy server for the given target. - -This is the recommended way to create a proxy server. For lower-level control, -use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.proxy`. - -**Args:** -- `target`: The backend to proxy to. Can be\: -- A Client instance (connected or disconnected) -- A ClientTransport -- A FastMCP server instance -- A URL string or AnyUrl -- A Path to a server script -- An MCPConfig or dict -- `mode`: Protocol-era negotiation for auto-created proxy clients (a -non-Client target). By default (``None``) the backend MIRRORS the -front connection's negotiated era per request, so the whole chain -speaks one era end-to-end\: a modern front reaches a modern backend -(a guard tool's `InputRequiredResult` (SEP-2322) round-trips) and a -handshake front reaches a handshake backend (server-initiated -sampling / elicitation / roots push-forwarding works). Pass an -explicit mode (e.g. ``"auto"`` or a version string) to pin the -backend era regardless of the front; this overrides mirroring and is -appropriate when the backend only speaks one era. Ignored when -`target` is already a `Client` (which carries its own mode). -- `**settings`: Additional settings passed to FastMCPProxy (name, etc.) - -**Returns:** -- A FastMCPProxy server that proxies to the target. - - -## Classes - -### `StateValue` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L272" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Wrapper for stored context state values. - - -### `FastMCP` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L278" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -**Methods:** - -#### `name` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L507" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -name(self) -> str -``` - -#### `instructions` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L511" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -instructions(self) -> str | None -``` - -#### `instructions` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L515" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -instructions(self, value: str | None) -> None -``` - -#### `version` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L519" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -version(self) -> str | None -``` - -#### `website_url` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L523" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -website_url(self) -> str | None -``` - -#### `icons` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L527" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -icons(self) -> list[mcp_types.Icon] -``` - -#### `local_provider` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L534" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -local_provider(self) -> LocalProvider -``` - -The server's local provider, which stores directly-registered components. - -Use this to remove components: - - mcp.local_provider.remove_tool("my_tool") - mcp.local_provider.remove_resource("data://info") - mcp.local_provider.remove_prompt("my_prompt") - - -#### `add_middleware` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L598" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_middleware(self, middleware: Middleware) -> None -``` - -#### `add_extension` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L601" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_extension(self, extension: ServerExtension) -> None -``` - -Register a server extension (SEP-2133). - -An extension contributes a negotiated capability, additive request -methods, a `tools/call` interceptor, and an optional lifespan — each -with access to FastMCP-level constructs (the component registry, -`Context`, auth scope). Its capability is advertised only while it is -registered. - -The extension is bound to this server (so its handlers and interceptor -can reach it), its method bindings are wired onto the low-level server, -and it is recorded for capability advertisement, interception, and -lifespan entry. Registering two extensions with the same identifier is -an error, as is registering after the server's lifespan has started — -the extension's lifespan could no longer run, leaving it silently -half-active. - -Extensions are served by the server they are registered on. A mounted -child's extensions do not propagate to the root: the root serves the -wire, so only root-registered extensions advertise capabilities and -answer methods (matching the lifespan, which also defers to the root). -Register extensions on the server you run. - - -#### `add_provider` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L673" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_provider(self, provider: Provider) -> None -``` - -Add a provider for dynamic tools, resources, and prompts. - -Providers are queried in registration order. The first provider to return -a non-None result wins. Static components (registered via decorators) -always take precedence over providers. - -**Args:** -- `provider`: A Provider instance that will provide components dynamically. -- `namespace`: Optional namespace prefix. When set\: -- Tools become "namespace_toolname" -- Resources become "protocol\://namespace/path" -- Prompts become "namespace_promptname" - - -#### `get_tasks` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L785" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_tasks(self) -> Sequence[FastMCPComponent] -``` - -Get task-eligible components with all transforms applied. - -Overrides AggregateProvider.get_tasks() to apply server-level transforms -after aggregation. AggregateProvider handles provider-level namespacing. - - -#### `add_transform` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L814" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_transform(self, transform: Transform) -> None -``` - -Add a server-level transform. - -Server-level transforms are applied after all providers are aggregated. -They transform tools, resources, and prompts from ALL providers. - -**Args:** -- `transform`: The transform to add. - - -#### `list_tools` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L834" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_tools(self) -> Sequence[Tool] -``` - -List all enabled tools from providers. - -Overrides Provider.list_tools() to add enabled filtering, auth filtering, -and middleware execution. Returns all versions (no deduplication). -Protocol handlers deduplicate for MCP wire format. - - -#### `get_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L917" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None -``` - -Get a tool by name, filtering disabled tools. - -Overrides Provider.get_tool() to filter disabled tools after all -transforms (including session-level) have been applied. This ensures -session transforms can override provider-level disables. - -When the highest version is disabled and no explicit version was -requested, falls back to the next-highest enabled version. - -**Args:** -- `name`: The tool name. -- `version`: Version filter (None returns highest version). - -**Returns:** -- The tool if found and enabled, None otherwise. - - -#### `list_resources` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L971" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_resources(self) -> Sequence[Resource] -``` - -List all enabled resources from providers. - -Overrides Provider.list_resources() to add visibility filtering, auth filtering, -and middleware execution. Returns all versions (no deduplication). -Protocol handlers deduplicate for MCP wire format. - - -#### `get_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1056" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None -``` - -Get a resource by URI, filtering disabled resources. - -Overrides Provider.get_resource() to add visibility filtering after all -transforms (including session-level) have been applied. - -When the highest version is disabled and no explicit version was -requested, falls back to the next-highest enabled version. - -**Args:** -- `uri`: The resource URI. -- `version`: Version filter (None returns highest version). - -**Returns:** -- The resource if found and enabled, None otherwise. - - -#### `list_resource_templates` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_resource_templates(self) -> Sequence[ResourceTemplate] -``` - -List all enabled resource templates from providers. - -Overrides Provider.list_resource_templates() to add visibility filtering, -auth filtering, and middleware execution. Returns all versions (no deduplication). -Protocol handlers deduplicate for MCP wire format. - - -#### `get_resource_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1188" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None -``` - -Get a resource template by URI, filtering disabled templates. - -Overrides Provider.get_resource_template() to add visibility filtering after -all transforms (including session-level) have been applied. - -When the highest version is disabled and no explicit version was -requested, falls back to the next-highest enabled version. - -**Args:** -- `uri`: The template URI. -- `version`: Version filter (None returns highest version). - -**Returns:** -- The template if found and enabled, None otherwise. - - -#### `list_prompts` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1242" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_prompts(self) -> Sequence[Prompt] -``` - -List all enabled prompts from providers. - -Overrides Provider.list_prompts() to add visibility filtering, auth filtering, -and middleware execution. Returns all versions (no deduplication). -Protocol handlers deduplicate for MCP wire format. - - -#### `get_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1314" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None -``` - -Get a prompt by name, filtering disabled prompts. - -Overrides Provider.get_prompt() to add visibility filtering after all -transforms (including session-level) have been applied. - -When the highest version is disabled and no explicit version was -requested, falls back to the next-highest enabled version. - -**Args:** -- `name`: The prompt name. -- `version`: Version filter (None returns highest version). - -**Returns:** -- The prompt if found and enabled, None otherwise. - - -#### `call_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1364" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult -``` - -Call a tool by name. - -This is the public API for executing tools. By default, middleware is applied. - -**Args:** -- `name`: The tool name -- `arguments`: Tool arguments (optional) -- `version`: Specific version to call. If None, calls highest version. -- `run_middleware`: If True (default), apply the middleware chain. -Set to False when called from middleware to avoid re-applying. - -**Returns:** -- ToolResult. - -A guard tool that requests client input (SEP-2322 multi-round-trip) -returns an ``InputRequiredToolResult`` (a ``ToolResult`` subclass); it -flows back through the middleware chain as an ordinary result and the -wire handler unwraps it into an ``InputRequiredResult`` on the response. - -**Raises:** -- `NotFoundError`: If tool not found or disabled -- `ToolError`: If tool execution fails -- `ValidationError`: If arguments fail validation - - -#### `read_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1557" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -read_resource(self, uri: str) -> ResourceResult -``` - -Read a resource by URI. - -This is the public API for reading resources. By default, middleware is applied. -Checks concrete resources first, then templates. - -**Args:** -- `uri`: The resource URI -- `version`: Specific version to read. If None, reads highest version. -- `run_middleware`: If True (default), apply the middleware chain. -Set to False when called from middleware to avoid re-applying. - -**Returns:** -- ResourceResult. - -**Raises:** -- `NotFoundError`: If resource not found or disabled -- `ResourceError`: If resource read fails - - -#### `render_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1715" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult -``` - -Render a prompt by name. - -This is the public API for rendering prompts. By default, middleware is applied. -Use get_prompt() to retrieve the prompt definition without rendering. - -**Args:** -- `name`: The prompt name -- `arguments`: Prompt arguments (optional) -- `version`: Specific version to render. If None, renders highest version. -- `run_middleware`: If True (default), apply the middleware chain. -Set to False when called from middleware to avoid re-applying. - -**Returns:** -- PromptResult. - -**Raises:** -- `NotFoundError`: If prompt not found or disabled -- `PromptError`: If prompt rendering fails - - -#### `add_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1795" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_tool(self, tool: Tool | Callable[..., Any]) -> Tool -``` - -Add a tool to the server. - -The tool function can optionally request a Context object by adding a parameter -with the Context type annotation. See the @tool decorator for examples. - -**Args:** -- `tool`: The Tool instance or @tool-decorated function to register - -**Returns:** -- The tool instance that was added to the server. - - -#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1810" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -tool(self, name_or_fn: F) -> F -``` - -#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1831" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -tool(self, name_or_fn: str | None = None) -> Callable[[F], F] -``` - -#### `tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1851" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] -``` - -Decorator to register a tool. - -Tools can optionally request a Context object by adding a parameter with the -Context type annotation. The context provides access to MCP capabilities like -logging, progress reporting, and resource access. - -This decorator supports multiple calling patterns: -- @server.tool (without parentheses) -- @server.tool (with empty parentheses) -- @server.tool("custom_name") (with name as first argument) -- @server.tool(name="custom_name") (with name as keyword argument) -- server.tool(function, name="custom_name") (direct function call) - -**Args:** -- `name_or_fn`: Either a function (when used as @tool), a string name, or None -- `name`: Optional name for the tool (keyword-only, alternative to name_or_fn) -- `description`: Optional description of what the tool does -- `tags`: Optional set of tags for categorizing the tool -- `output_schema`: Optional JSON schema for the tool's output -- `annotations`: Optional annotations about the tool's behavior -- `meta`: Optional meta information about the tool - -**Examples:** - -Register a tool with a custom name: -```python -@server.tool -def my_tool(x: int) -> str: - return str(x) - -# Register a tool with a custom name -@server.tool -def my_tool(x: int) -> str: - return str(x) - -@server.tool("custom_name") -def my_tool(x: int) -> str: - return str(x) - -@server.tool(name="custom_name") -def my_tool(x: int) -> str: - return str(x) - -# Direct function call -server.tool(my_function, name="custom_name") -``` - - -#### `add_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1948" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate -``` - -Add a resource to the server. - -**Args:** -- `resource`: A Resource instance or @resource-decorated function to add - -**Returns:** -- The resource instance that was added to the server. - - -#### `add_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1961" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_template(self, template: ResourceTemplate) -> ResourceTemplate -``` - -Add a resource template to the server. - -**Args:** -- `template`: A ResourceTemplate instance to add - -**Returns:** -- The template instance that was added to the server. - - -#### `resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L1972" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -resource(self, uri: str) -> Callable[[F], F] -``` - -Decorator to register a function as a resource. - -The function will be called when the resource is read to generate its content. -The function can return: -- str for text content -- bytes for binary content -- other types will be converted to JSON - -Resources can optionally request a Context object by adding a parameter with the -Context type annotation. The context provides access to MCP capabilities like -logging, progress reporting, and session information. - -If the URI contains parameters (e.g. "resource://{param}") or the function -has parameters, it will be registered as a template resource. - -**Args:** -- `uri`: URI for the resource (e.g. "resource\://my-resource" or "resource\://{param}") -- `name`: Optional name for the resource -- `description`: Optional description of the resource -- `mime_type`: Optional MIME type for the resource -- `tags`: Optional set of tags for categorizing the resource -- `annotations`: Optional annotations about the resource's behavior -- `meta`: Optional meta information about the resource - -**Examples:** - -Register a resource with a custom name: -```python -@server.resource("resource://my-resource") -def get_data() -> str: - return "Hello, world!" - -@server.resource("resource://my-resource") -async get_data() -> str: - data = await fetch_data() - return f"Hello, world! {data}" - -@server.resource("resource://{city}/weather") -def get_weather(city: str) -> str: - return f"Weather for {city}" - -@server.resource("resource://{city}/weather") -async def get_weather_with_context(city: str, ctx: Context) -> str: - await ctx.info(f"Fetching weather for {city}") - return f"Weather for {city}" - -@server.resource("resource://{city}/weather") -async def get_weather(city: str) -> str: - data = await fetch_weather(city) - return f"Weather for {city}: {data}" -``` - - -#### `add_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2091" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt -``` - -Add a prompt to the server. - -**Args:** -- `prompt`: A Prompt instance or @prompt-decorated function to add - -**Returns:** -- The prompt instance that was added to the server. - - -#### `prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2103" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -prompt(self, name_or_fn: F) -> F -``` - -#### `prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -prompt(self, name_or_fn: str | None = None) -> Callable[[F], F] -``` - -#### `prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] -``` - -Decorator to register a prompt. - - Prompts can optionally request a Context object by adding a parameter with the - Context type annotation. The context provides access to MCP capabilities like - logging, progress reporting, and session information. - - This decorator supports multiple calling patterns: - - @server.prompt (without parentheses) - - @server.prompt() (with empty parentheses) - - @server.prompt("custom_name") (with name as first argument) - - @server.prompt(name="custom_name") (with name as keyword argument) - - server.prompt(function, name="custom_name") (direct function call) - - Args: - name_or_fn: Either a function (when used as @prompt), a string name, or None - name: Optional name for the prompt (keyword-only, alternative to name_or_fn) - description: Optional description of what the prompt does - tags: Optional set of tags for categorizing the prompt - meta: Optional meta information about the prompt - - Examples: - - ```python - @server.prompt - def analyze_table(table_name: str) -> list[Message]: - schema = read_table_schema(table_name) - return [ - { - "role": "user", - "content": f"Analyze this schema: -{schema}" - } - ] - - @server.prompt() - async def analyze_with_context(table_name: str, ctx: Context) -> list[Message]: - await ctx.info(f"Analyzing table {table_name}") - schema = read_table_schema(table_name) - return [ - { - "role": "user", - "content": f"Analyze this schema: -{schema}" - } - ] - - @server.prompt("custom_name") - async def analyze_file(path: str) -> list[Message]: - content = await read_file(path) - return [ - { - "role": "user", - "content": { - "type": "resource", - "resource": { - "uri": f"file://{path}", - "text": content - } - } - } - ] - - @server.prompt(name="custom_name") - def another_prompt(data: str) -> list[Message]: - return [{"role": "user", "content": data}] - - # Direct function call - server.prompt(my_function, name="custom_name") - ``` - - -#### `add_completion_handler` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2230" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -add_completion_handler(self, handler: CompletionHandler) -> None -``` - -Register the server's argument-completion handler. - -A server has a single completion handler that answers every -`completion/complete` request, switching on the reference (a prompt or -resource template) and the argument being completed. Registering it also -registers the low-level `completion/complete` handler, which is what -makes the SDK declare the completions capability — so the capability is -advertised exactly when the server can answer. Calling this again -replaces the handler. - -**Args:** -- `handler`: A callable taking the reference, the -`CompletionArgument`, and the optional `CompletionContext`, and -returning candidate values (a `Completion`, a list of strings, -or None). May be sync or async. - - -#### `completion` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2251" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -completion(self, handler: CompletionHandler) -> CompletionHandler -``` - -#### `completion` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -completion(self) -> Callable[[CompletionHandler], CompletionHandler] -``` - -#### `completion` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2258" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -completion(self, handler: CompletionHandler | None = None) -> CompletionHandler | Callable[[CompletionHandler], CompletionHandler] -``` - -Decorator to register the server's argument-completion handler. - -The handler answers `completion/complete` requests for prompt arguments -and resource-template parameters. It receives the reference being -completed, the argument (its name and the partial value typed so far), -and the context of arguments already supplied, and returns candidate -values. Return a list of strings, a `Completion` (to include pagination -hints), or None when the reference/argument is not one it handles — an -unhandled reference yields an empty completion, not an error. - -Registering a handler declares the completions capability; a server with -none does not advertise it. This works identically on the handshake and -modern protocol eras. - -Supports both `@mcp.completion` and `@mcp.completion()`. - -Example: - - ```python - from fastmcp import FastMCP - from mcp_types import Completion, PromptReference - - mcp = FastMCP("Completion Server") - - @mcp.prompt - def poem(theme: str) -> str: - return f"Write a poem about {theme}" - - @mcp.completion - def complete(ref, argument, context): - if isinstance(ref, PromptReference) and ref.name == "poem": - if argument.name == "theme": - options = ["nature", "love", "adventure"] - return [o for o in options if o.startswith(argument.value)] - return None - ``` - - -#### `mount` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2308" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, tool_names: dict[str, str] | None = None) -> None -``` - -Mount another FastMCP server on this server with an optional namespace. - -Mounting establishes a dynamic connection between servers. When a client -interacts with a mounted server's objects through the parent server, requests -are forwarded to the mounted server in real-time. This means changes to the -mounted server are immediately reflected when accessed through the parent. - -When a server is mounted with a namespace: -- Tools from the mounted server are accessible with namespaced names. - Example: If server has a tool named "get_weather", it will be available as "namespace_get_weather". -- Resources are accessible with namespaced URIs. - Example: If server has a resource with URI "weather://forecast", it will be available as - "weather://namespace/forecast". -- Templates are accessible with namespaced URI templates. - Example: If server has a template with URI "weather://location/{id}", it will be available - as "weather://namespace/location/{id}". -- Prompts are accessible with namespaced names. - Example: If server has a prompt named "weather_prompt", it will be available as - "namespace_weather_prompt". - -When a server is mounted without a namespace (namespace=None), its tools, resources, templates, -and prompts are accessible with their original names. Multiple servers can be mounted -without namespaces, and they will be tried in order until a match is found. - -The mounted server's lifespan is executed when the parent server starts, and its -middleware chain is invoked for all operations (tool calls, resource reads, prompts). - -**Args:** -- `server`: The FastMCP server to mount. -- `namespace`: Optional namespace to use for the mounted server's objects. If None, -the server's objects are accessible with their original names. -- `tool_names`: Optional mapping of original tool names to custom names. Use this -to override namespaced names. Keys are the original tool names from the -mounted server. - - -#### `from_openapi` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2379" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -from_openapi(cls, openapi_spec: dict[str, Any], client: httpx2.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self -``` - -Create a FastMCP server from an OpenAPI specification. - -**Args:** -- `openapi_spec`: OpenAPI schema as a dictionary -- `client`: Optional httpx2 AsyncClient for making HTTP requests. -If not provided, a default client is created using the first -server URL from the OpenAPI spec with a 30-second timeout. -Legacy httpx clients are temporarily accepted with a deprecation -warning. -- `name`: Name for the MCP server -- `route_maps`: Optional list of RouteMap objects defining route mappings -- `route_map_fn`: Optional callable for advanced route type mapping -- `mcp_component_fn`: Optional callable for component customization -- `mcp_names`: Optional dictionary mapping operationId to component names -- `tags`: Optional set of tags to add to all components -- `validate_output`: If True (default), tools use the output schema -extracted from the OpenAPI spec for response validation. If -False, a permissive schema is used instead, allowing any -response structure while still returning structured JSON. -- `**settings`: Additional settings passed to FastMCP - -**Returns:** -- A FastMCP server with an OpenAPIProvider attached. - - -#### `from_fastapi` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2432" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self -``` - -Create a FastMCP server from a FastAPI application. - -**Args:** -- `app`: FastAPI application instance -- `name`: Name for the MCP server (defaults to app.title) -- `route_maps`: Optional list of RouteMap objects defining route mappings -- `route_map_fn`: Optional callable for advanced route type mapping -- `mcp_component_fn`: Optional callable for component customization -- `mcp_names`: Optional dictionary mapping operationId to component names -- `httpx_client_kwargs`: Optional kwargs passed to httpx2.AsyncClient. -Use this to configure timeout and other client settings. -- `tags`: Optional set of tags to add to all components -- `**settings`: Additional settings passed to FastMCP - -**Returns:** -- A FastMCP server with an OpenAPIProvider attached. - - -#### `generate_name` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/server.py#L2487" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -generate_name(cls, name: str | None = None) -> str -``` diff --git a/docs/python-sdk/fastmcp-server-session_scoped_event_store.mdx b/docs/python-sdk/fastmcp-server-session_scoped_event_store.mdx deleted file mode 100644 index b595a435b..000000000 --- a/docs/python-sdk/fastmcp-server-session_scoped_event_store.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: session_scoped_event_store -sidebarTitle: session_scoped_event_store ---- - -# `fastmcp.server.session_scoped_event_store` - - -Lightweight session scoping for Streamable HTTP event stores. - -## Classes - -### `SessionScopedEventStore` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/session_scoped_event_store.py#L19" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -EventStore adapter that isolates stream IDs to one transport session. - - -**Methods:** - -#### `store_event` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/session_scoped_event_store.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId -``` - -#### `replay_events_after` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/session_scoped_event_store.py#L41" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None -``` diff --git a/docs/python-sdk/fastmcp-server-sessions.mdx b/docs/python-sdk/fastmcp-server-sessions.mdx deleted file mode 100644 index 0caea6b13..000000000 --- a/docs/python-sdk/fastmcp-server-sessions.mdx +++ /dev/null @@ -1,319 +0,0 @@ ---- -title: sessions -sidebarTitle: sessions ---- - -# `fastmcp.server.sessions` - - -Stateless session state: server-side per-user and per-session storage. - -Modern (2026-07-28) MCP connections are stateless by construction — every -request builds a fresh connection whose in-memory state is discarded when the -request returns. This module gives tools two explicit ways to keep state across -calls, both backed by the server's existing state store and both isolated by the -authenticated principal rather than by any client-declared identifier. - -- `Session`: async `get`/`set`/`delete`/`clear` over a single dict stored under - one key, scoped to a `(principal, session_id)` pair. This is the state-accessor - object a handler works with — the value the standalone `get_session(id)` - returns and the value injected for a `UserSession` parameter. -- `session: UserSession` (injected): a per-user bucket, dependency-injected like - `ctx: Context` and keyed by the request's authenticated principal. Requires - auth. `UserSession` is the injection annotation; the injected value is a - `Session`. It is always available under auth — no `create_session`, no - provider, no validation. -- `session_id: SessionId` (argument): a required string the agent supplies, - resolved with the standalone `await get_session(session_id)`. The id is - minted - by `create_session`; an id that was never created (or was created under a - different principal) is rejected. This validation is the whole guarantee — an - unminted id never resolves, so nothing enforces provider registration. -- `SessionProvider`: a `Provider` contributing `create_session` / `end_session` - tools. Register it with `mcp.add_provider(SessionProvider())` so a tool that - takes `session_id` has a way to mint ids; without it, no id can be created, so - those tools simply cannot resolve a session. - -Isolation is the authenticated principal, not the session id. State keyed by -`(principal, session_id)` means a request under principal B can never address -principal A's keys, no matter what `session_id` it passes; the id only organizes -sessions within a principal. Without auth there is no principal wall — a session -id is a bearer capability and sessions are not a boundary between clients. - - -## Functions - -### `current_principal` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L139" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -current_principal() -> str | None -``` - - -The authenticated principal for the current request as a compact JSON string. - -Returns the `(client_id, issuer, subject)` triple encoded as compact JSON, or -`None` on an unauthenticated request. Two users of one OAuth client are -distinct principals whenever the token verifier supplies a subject. - - -### `session_storage_key` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L164" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -session_storage_key(principal: str | None, session_id: str) -> str -``` - - -The single storage key holding a session's state dict. - -Keyed by `(principal, session_id)`: the principal is the isolation wall, the -id organizes sessions within it. A session's whole state lives under this one -key as a dict, so one key means one store TTL per session and `end` is a -single delete. - - -### `session_id_parameter_names` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L343" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -session_id_parameter_names(fn: Callable[..., object]) -> tuple[str, ...] -``` - - -Names of a function's parameters annotated with `SessionId`. - -Scans resolved type hints for `Annotated[str, _SessionIdMarker()]` metadata. -Returns an empty tuple when the hints cannot be resolved (the function then -simply carries no auto-populated session-id description). - -`functools.partial` is unwrapped first, since `get_type_hints` rejects a -partial object — FastMCP supports registering a partial as a tool, and its -schema is still built from the underlying function, so its `SessionId` -parameters must be detected here too. Parameters the partial has already -bound — positionally or by keyword — are dropped, matching the tool's actual -argument surface (the partial's own signature already reflects this). - - -### `CurrentSession` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L449" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -CurrentSession() -> Session -``` - - -Inject the per-user `Session` for the current authenticated principal. - -Rarely written explicitly — a `session: UserSession` parameter is rewritten -to this. Provided for parity with `CurrentContext()` when an explicit default -is preferred. - - -### `OptionalCurrentSession` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L459" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -OptionalCurrentSession() -> Session | None -``` - - -Inject the per-user `Session`, or `None` when the request is unauthenticated. - -Rarely written explicitly — a `session: UserSession | None = None` parameter -is rewritten to this. Provided for parity with `OptionalCurrentContext()`. - - -### `create_session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L468" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -create_session() -> str -``` - - -Create a new session and return its identifier. - -Mints an unguessable `uuid4`, records an initial session owned by the current -principal, and returns the id as a string. Store it and pass it back as a -`session_id` argument on later calls to persist state across a session — only -an id created this way resolves. State is keyed by the authenticated -principal, so the id organizes sessions within a user; on an unauthenticated -connection the id is the only thing standing between callers, which is why it -is unguessable. - - -### `end_session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L490" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -end_session(session_id: SessionId) -> str -``` - - -End a session and delete all of its state. - -Validates the id like any other resolution (an unknown or foreign id is -rejected), then deletes the session's key so the id no longer resolves. - - -## Classes - -### `SessionAuthError` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -An injected `session: UserSession` was requested with no authenticated principal. - -Per-user session injection keys off the request's authenticated principal, so -it is only meaningful under auth. A tool that needs cross-call state without -auth should take a `session_id: SessionId` argument instead. - - -### `InvalidSession` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -A session id did not resolve to a session created under the current principal. - -Raised by `get_session(session_id)` when the id was never created, or was -created under a different principal. The public message is deliberately -generic — the specific reason (which id, which principal) is logged at debug -level, not returned to the caller, so an attacker cannot distinguish "unknown -id" from "belongs to someone else". - - -### `Session` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L175" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Async accessors over one `(principal, session_id)` bucket of state. - -A session's state is a single dict stored under one key. That dict holds user -state in a `state` sub-dict and a small creation marker alongside it, so a -created-but-empty session is still distinguishable from a missing one. -`get`/`set`/`delete` read-modify-write the sub-dict; `clear` empties the -sub-dict but keeps the session valid; `end` deletes the whole key. Writes -never impose a TTL — retention is entirely the server store's (configure it on -the store you pass to `FastMCP(session_state_store=...)`). - -Concurrent writes to one session race on the read-modify-write; session state -is small and typically driven serially by one agent, so this is acceptable. - - -**Methods:** - -#### `id` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L205" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -id(self) -> str | None -``` - -The session's identifier, or `None` for an injected per-user session. - -For a session resolved from a `session_id` argument (or minted by -`create_session`) this is that id. An injected `UserSession` has no -distinct id — its bucket is the authenticated user — so it is `None`; the -internal principal-derived key is deliberately not exposed here. - - -#### `get` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get(self, key: str, default: Any = None) -> Any -``` - -Return the value for `key`, or `default` when it is not set. - - -#### `set` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L259" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -set(self, key: str, value: Any) -> None -``` - -Store `value` under `key` in this session (read-modify-write). - -Preserves the creation marker: only the user-state sub-dict is touched. - - -#### `delete` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L270" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -delete(self, key: str) -> None -``` - -Remove `key` from this session, if present (preserves the marker). - - -#### `clear` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L281" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -clear(self) -> None -``` - -Empty the session's user state but keep the session valid. - -The user-state sub-dict is reset to empty while the creation marker stays -in place, so a cleared session still resolves through `get_session`. -To invalidate a session entirely, use `end` (what `end_session` calls). - - -#### `end` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L294" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -end(self) -> None -``` - -Invalidate the session — delete its one key and all of its state. - -After this the id no longer resolves through `get_session`. This is -what `end_session` calls; `clear` only empties state and keeps the session. - - -### `UserSession` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L303" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Annotation marker for the injected per-user session. - -A `session: UserSession` parameter is **dependency-injected** like -`ctx: Context`: keyed by the request's authenticated principal, excluded from -the input schema, and requiring auth (it raises `SessionAuthError` with no -principal). It doubles as the injection *annotation* and the injected -type — the value a handler receives is a `UserSession`, which subclasses -`Session`, so `await session.get(...)`, `.set`, `.delete`, and `.clear` all -work exactly as on any other `Session`. - -Unlike `session_id: SessionId`, the per-user bucket needs no `create_session`, -no `SessionProvider`, and no validation — it is always available under auth, -keyed directly by the caller's identity. - -```python -from fastmcp.server.sessions import UserSession - -@mcp.tool -async def remember(fact: str, session: UserSession) -> str: - await session.set("fact", fact) - return "noted" -``` - -Subclasses `Session` only so the framework's type-based injection detector can -key off it; it adds no behavior of its own. - - -### `SessionProvider` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/sessions.py#L501" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Provider contributing the session lifecycle tools. - -Register it whenever a tool declares a `session_id: SessionId` argument: - -```python -from fastmcp.server.sessions import SessionProvider - -mcp.add_provider(SessionProvider()) -``` - -It registers two tools: - -- `create_session()` mints an unguessable `uuid4`, records the session, and - returns the id. -- `end_session(session_id)` invalidates that session and deletes its state. - -It owns no storage (session state lives in the server's configured -`session_state_store`) and imposes no TTL (retention is the store's). It -exists to mint and end owned session ids. Registration is not enforced: with -no provider, no id can be created, so every `get_session(...)` rejects — -a `session_id` tool without a provider simply cannot resolve a session. - diff --git a/docs/python-sdk/fastmcp-server-telemetry.mdx b/docs/python-sdk/fastmcp-server-telemetry.mdx deleted file mode 100644 index 874fcf1e9..000000000 --- a/docs/python-sdk/fastmcp-server-telemetry.mdx +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: telemetry -sidebarTitle: telemetry ---- - -# `fastmcp.server.telemetry` - - -Server-side telemetry helpers. - -## Functions - -### `get_auth_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L43" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_auth_span_attributes() -> dict[str, str] -``` - - -Get auth attributes for the current request, if authenticated. - - -### `get_session_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_session_span_attributes() -> dict[str, str] -``` - - -Get session attributes for the current request. - - -### `get_protocol_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L74" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_protocol_span_attributes() -> dict[str, str] -``` - - -Get the negotiated MCP protocol version for the current request. - -Mirrors the `mcp.protocol.version` attribute the SDK's own -`OpenTelemetryMiddleware` sets — FastMCP drops that middleware to avoid a -duplicate SERVER span, so this restores the attribute on FastMCP's span. - - -### `record_span_exception` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L154" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -record_span_exception(span: Span, e: Exception) -> None -``` - - -Record an exception and error status on a span. - - -### `seam_span` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L164" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -seam_span(method: str, server_name: str) -> Generator[Span, None, None] -``` - - -Open the per-request SERVER span at the FastMCP middleware seam. - -The span is named after the method and carries the base MCP attributes -(`mcp.method.name`, `fastmcp.server.name`, auth/session context) so -seam-only methods (`logging/setLevel`, `tasks/*`, `ping`, `initialize`, ...) -are fully attributed even though they never reach the high-level path. It is -marked with `SEAM_SPAN_MARKER` so a later `server_span` call in the -high-level path enriches this span with component attributes instead of -opening a second one. Exceptions raised anywhere below the seam — including -rejections *before* the high-level path (auth, not-found, middleware vetoes) -that would otherwise produce no SERVER span at all — are recorded here. - -In `propagation_only` mode no span is opened at all — this is the one place -that has to know the difference, because the seam is where the incoming -`_meta` parent context is applied for the whole request. - - -### `server_span` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L224" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -server_span(name: str, method: str, server_name: str, component_type: str, component_key: str, resource_uri: str | None = None, tool_name: str | None = None, prompt_name: str | None = None) -> Generator[Span, None, None] -``` - - -Emit or enrich a SERVER span with standard MCP attributes and auth context. - -When the current active span is the request's seam span (opened by -`FastMCPServerMiddleware` and marked with `SEAM_SPAN_MARKER`), this sets the -component attributes on that span and yields it *without* starting a second -span — so failures rejected before this point and the successful high-level -call share one richly-attributed SERVER span. Otherwise (non-seam contexts, -e.g. in-process `mcp.call_tool()` calls that bypass the dispatcher) it opens a -new SERVER span as before. - -Automatically records any exception on the span and sets error status. - -In `propagation_only` mode no span is opened or enriched. The seam has -normally already attached the incoming parent context for this request; -doing it again here is a no-op, and covers the in-process callers that -bypass the dispatcher and so never reach the seam at all. - - -### `delegate_span` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/telemetry.py#L305" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -delegate_span(name: str, provider_type: str, component_key: str, method: str | None = None) -> Generator[Span, None, None] -``` - - -Create an INTERNAL span for provider delegation. - -Used by FastMCPProvider when delegating to mounted servers. -Automatically records any exception on the span and sets error status. - diff --git a/docs/python-sdk/fastmcp-server-transforms.mdx b/docs/python-sdk/fastmcp-server-transforms.mdx deleted file mode 100644 index 7e6d19054..000000000 --- a/docs/python-sdk/fastmcp-server-transforms.mdx +++ /dev/null @@ -1,193 +0,0 @@ ---- -title: transforms -sidebarTitle: transforms ---- - -# `fastmcp.server.transforms` - - -Transform system for component transformations. - -Transforms modify components (tools, resources, prompts). List operations use a pure -function pattern where transforms receive sequences and return transformed sequences. -Get operations use a middleware pattern with `call_next` to chain lookups. - -Unlike middleware (which operates on requests), transforms are observable by the -system for task registration, tag filtering, and component introspection. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.transforms import Namespace - - server = FastMCP("Server") - mount = server.mount(other_server) - mount.add_transform(Namespace("api")) # Tools become api_toolname - ``` - - -## Classes - -### `GetToolNext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Protocol for get_tool call_next functions. - - -### `GetResourceNext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L44" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Protocol for get_resource call_next functions. - - -### `GetResourceTemplateNext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L52" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Protocol for get_resource_template call_next functions. - - -### `GetPromptNext` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Protocol for get_prompt call_next functions. - - -### `Transform` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L68" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Base class for component transformations. - -List operations use a pure function pattern: transforms receive sequences -and return transformed sequences. Get operations use a middleware pattern -with `call_next` to chain lookups. - - -**Methods:** - -#### `list_tools` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L95" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] -``` - -List tools with transformation applied. - -**Args:** -- `tools`: Sequence of tools to transform. - -**Returns:** -- Transformed sequence of tools. - - -#### `get_tool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_tool(self, name: str, call_next: GetToolNext) -> Tool | None -``` - -Get a tool by name. - -**Args:** -- `name`: The requested tool name (may be transformed). -- `call_next`: Callable to get tool from downstream. -- `version`: Optional version filter to apply. - -**Returns:** -- The tool if found, None otherwise. - - -#### `list_resources` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L125" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] -``` - -List resources with transformation applied. - -**Args:** -- `resources`: Sequence of resources to transform. - -**Returns:** -- Transformed sequence of resources. - - -#### `get_resource` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_resource(self, uri: str, call_next: GetResourceNext) -> Resource | None -``` - -Get a resource by URI. - -**Args:** -- `uri`: The requested resource URI (may be transformed). -- `call_next`: Callable to get resource from downstream. -- `version`: Optional version filter to apply. - -**Returns:** -- The resource if found, None otherwise. - - -#### `list_resource_templates` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L159" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] -``` - -List resource templates with transformation applied. - -**Args:** -- `templates`: Sequence of resource templates to transform. - -**Returns:** -- Transformed sequence of resource templates. - - -#### `get_resource_template` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L172" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_resource_template(self, uri: str, call_next: GetResourceTemplateNext) -> ResourceTemplate | None -``` - -Get a resource template by URI. - -**Args:** -- `uri`: The requested template URI (may be transformed). -- `call_next`: Callable to get template from downstream. -- `version`: Optional version filter to apply. - -**Returns:** -- The resource template if found, None otherwise. - - -#### `list_prompts` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L195" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] -``` - -List prompts with transformation applied. - -**Args:** -- `prompts`: Sequence of prompts to transform. - -**Returns:** -- Transformed sequence of prompts. - - -#### `get_prompt` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/server/transforms/__init__.py#L206" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None -``` - -Get a prompt by name. - -**Args:** -- `name`: The requested prompt name (may be transformed). -- `call_next`: Callable to get prompt from downstream. -- `version`: Optional version filter to apply. - -**Returns:** -- The prompt if found, None otherwise. - diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx index a0d999d27..87940be01 100644 --- a/docs/python-sdk/fastmcp-settings.mdx +++ b/docs/python-sdk/fastmcp-settings.mdx @@ -7,7 +7,13 @@ sidebarTitle: settings ## Classes -### `Settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `DocketSettings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L33" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> + + +Docket worker configuration. + + +### `Settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> FastMCP settings. @@ -15,7 +21,7 @@ FastMCP settings. **Methods:** -#### `get_setting` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `get_setting` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L148" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python get_setting(self, attr: str) -> Any @@ -25,7 +31,7 @@ Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `set_setting` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L59" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `set_setting` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L161" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python set_setting(self, attr: str, value: Any) -> None @@ -35,7 +41,7 @@ Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `normalize_log_level` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L81" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `normalize_log_level` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/settings.py#L183" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python normalize_log_level(cls, v) diff --git a/docs/python-sdk/fastmcp-telemetry.mdx b/docs/python-sdk/fastmcp-telemetry.mdx index 3cb06ca12..44d68cb78 100644 --- a/docs/python-sdk/fastmcp-telemetry.mdx +++ b/docs/python-sdk/fastmcp-telemetry.mdx @@ -31,52 +31,7 @@ Example usage with SDK: ## Functions -### `telemetry_mode` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L86" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -telemetry_mode() -> 'TelemetryMode' -``` - - -Resolve the effective telemetry mode for the current context. - -This is `fastmcp.settings.telemetry_mode`, except that an active -`suppress_fastmcp_telemetry()` block downgrades `native` to -`propagation_only`. Suppression never upgrades or overrides `off`: `off` -means FastMCP touches nothing, and a narrower request to skip FastMCP's -spans cannot re-enable the context propagation `off` deliberately omits. - - -### `native_spans_enabled` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L103" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -native_spans_enabled() -> bool -``` - - -Whether FastMCP should create its own spans right now. - - -### `suppress_fastmcp_telemetry` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L109" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -suppress_fastmcp_telemetry() -> Iterator[None] -``` - - -Suppress FastMCP's own spans without disabling trace propagation. - -Scoped equivalent of `telemetry_mode="propagation_only"`, for callers that -embed FastMCP inside their own instrumented stack and want to own the MCP -span hierarchy for a specific block. Narrower than OpenTelemetry's global -instrumentation suppression: only FastMCP's spans are skipped, so nested -instrumentation (HTTP clients, databases) keeps emitting, and trace context -still flows through `_meta` so those spans are parented correctly. - -Has no effect when `telemetry_mode` is already `off`. - - -### `get_tracer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `get_tracer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python get_tracer(version: str | None = None) -> Tracer @@ -85,24 +40,14 @@ get_tracer(version: str | None = None) -> Tracer Get the FastMCP tracer for creating spans. -Instrumentation is on by default. FastMCP uses only the OpenTelemetry API, -so span creation is a no-op with negligible overhead unless an OpenTelemetry -SDK and exporter are configured. When `fastmcp.settings.telemetry_mode` is -`propagation_only` or `off` — or the caller is inside a -`suppress_fastmcp_telemetry()` block — this returns a pass-through tracer -that creates no spans and leaves the current OTel context untouched even -when an SDK is configured. - **Args:** - `version`: Optional version string for the instrumentation **Returns:** -- A tracer instance. Returns a non-attaching pass-through tracer when -- FastMCP's own spans are disabled; span creation is otherwise a no-op -- unless an SDK is configured. +- A tracer instance. Returns a no-op tracer if no SDK is configured. -### `inject_trace_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L152" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `inject_trace_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L50" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python inject_trace_context(meta: dict[str, Any] | None = None) -> dict[str, Any] | None @@ -119,7 +64,7 @@ Inject current trace context into a meta dict for MCP request propagation. - or None if no trace context to inject and meta was None -### `record_span_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L183" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `record_span_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python record_span_error(span: Span, exception: BaseException) -> None @@ -129,57 +74,7 @@ record_span_error(span: Span, exception: BaseException) -> None Record an exception on a span and set error status. -### `restore_dropped_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L209" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -restore_dropped_attributes(span: Span, attrs: Mapping[str, otel_types.AttributeValue]) -> None -``` - - -Restore FastMCP attributes a non-forwarding sampler dropped entirely. - -`Tracer.start_span` builds the span from `SamplingResult.attributes`, not -the `attributes=` kwarg it was given for creation — a custom `Sampler` -whose `SamplingResult.attributes` defaults to `None` silently discards -every attribute FastMCP passed at creation time. Call this immediately -after span creation to recover from that case. - -The restore only fires when the span has *no* attributes at all AND the -SDK hasn't evicted anything (`dropped_attributes == 0`): - -- A bare, non-forwarding sampler (the regression this exists to fix) - leaves the span with an empty attribute mapping, so everything is - restored. -- A sampler that supplied any attributes of its own — whether by - forwarding ours untouched, redacting or replacing some of our values, - or substituting its own attributes entirely (e.g. to strip component - names or resource URIs for privacy or cardinality control) — leaves - the span non-empty, so it is left alone entirely. This is what makes - the gate precise: a sampler that deliberately supplies only its own - attributes must not have them clobbered by a restore that assumes - "no FastMCP keys" means "sampler forwarding failed." -- A sampler that forwards most of our attributes but deliberately drops - one is still non-empty, so it's covered by the same "leave alone" - branch — a dropped key here is indistinguishable from the SDK's - bounded attribute map evicting it, and reinserting it would just push - the map's bound and evict a *different* retained key, churning which - attributes survive without changing how many are lost. No attempt is - made to restore individual missing keys; the gate is all-or-nothing. -- A low `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT` that evicts every attribute a - forwarding sampler passed through is indistinguishable, from the - span's attribute state alone, from a bare non-forwarding sampler — - both leave an empty mapping. `dropped_attributes == 0` is what tells - them apart: eviction always increments it, so that case is correctly - excluded from the restore and the SDK's bounded map is left as - computed. - -Callers are expected to guard this with `if span.is_recording():`; it -does no work worth skipping for non-recording spans, but the check is -kept at call sites so it reads alongside the sibling `is_recording()` -guards already in those functions. - - -### `extract_trace_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L263" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `extract_trace_context` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/telemetry.py#L82" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python extract_trace_context(meta: dict[str, Any] | None) -> Context diff --git a/docs/python-sdk/fastmcp-utilities-asgi_transport.mdx b/docs/python-sdk/fastmcp-utilities-asgi_transport.mdx deleted file mode 100644 index 25fd6e2f8..000000000 --- a/docs/python-sdk/fastmcp-utilities-asgi_transport.mdx +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: asgi_transport -sidebarTitle: asgi_transport ---- - -# `fastmcp.utilities.asgi_transport` - - -An in-process, full-duplex HTTP transport for driving ASGI applications from httpx. - -Ported from the MCP Python SDK's test suite (`tests/interaction/transports/_bridge.py`, -MIT licensed). - -`httpx2.ASGITransport` runs the application to completion and only then hands the buffered -response to the caller, so a server that streams its response — as the streamable HTTP -transport's SSE responses do — can never converse with the client mid-request: a -server-initiated request nested inside a still-open call deadlocks. -`StreamingASGITransport` removes that limitation by running the application as a background -task and forwarding every `http.response.body` chunk to the client the moment it is sent. -Everything happens on the one event loop: no sockets, no threads, no sleeps. - -The behavioural contract: - -- The request body is buffered before the application is invoked (MCP requests are small - JSON documents); the response streams chunk by chunk. -- Closing the response — or the whole client — delivers `http.disconnect` to the - application, exactly as a real server sees when its peer goes away. -- An exception the application raises before sending `http.response.start` fails the - originating request with that same exception. After the response has started, a failure - is visible to the client only through the response itself (status code, truncated body) — - the same signal a real server over a real socket would give. - -The transport owns an anyio task group for the application tasks; it is opened and closed by -`httpx2.AsyncClient`'s own context manager, so the client must be used as a context manager. -Closing the transport cancels every running application task by default; set -`cancel_on_close=False` to wait for the application's own disconnect handling instead, which -is what the legacy SSE transport relies on for resource cleanup. - - -## Functions - -### `run_asgi_lifespan` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/asgi_transport.py#L224" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -run_asgi_lifespan(app: ASGIApp) -> AsyncIterator[None] -``` - - -Run an ASGI application's lifespan, driving the protocol as a real server does. - -The application's lifespan runs inside a dedicated task for the whole duration of -the context. This matters because a lifespan typically owns cancel scopes and task -groups — anyio requires those to be exited by the task that entered them, which -rules out entering the lifespan on one task and leaving it on another (as a pytest -fixture's setup and teardown phases may do). - -**Args:** -- `app`: The ASGI application whose lifespan should run. - -**Raises:** -- `RuntimeError`: If the application reports `lifespan.startup.failed`, or reports -`lifespan.shutdown.failed` (or crashes during shutdown) while the context -body itself completed successfully. A failure inside the body takes -precedence and propagates unchanged. - - -## Classes - -### `StreamingASGITransport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/asgi_transport.py#L71" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -Drive an ASGI application in-process, streaming each response as it is produced. - -This is an `httpx2` transport, so it plugs into anything that accepts an -`httpx2.AsyncClient` — including FastMCP's client transports via their -`httpx_client_factory` argument. - -**Args:** -- `app`: The ASGI application to drive (e.g. `FastMCP.http_app()`). -- `cancel_on_close`: When True (the default), closing the transport cancels every -application task still running, so harness teardown can never hang. Set to -False to wait for the application's own disconnect handling to complete -instead, which the legacy SSE server transport relies on for cleanup. - - -**Methods:** - -#### `handle_async_request` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/asgi_transport.py#L128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -handle_async_request(self, request: httpx2.Request) -> httpx2.Response -``` diff --git a/docs/python-sdk/fastmcp-utilities-async_utils.mdx b/docs/python-sdk/fastmcp-utilities-async_utils.mdx index 4d6a996e9..361400e1c 100644 --- a/docs/python-sdk/fastmcp-utilities-async_utils.mdx +++ b/docs/python-sdk/fastmcp-utilities-async_utils.mdx @@ -37,10 +37,10 @@ Uses anyio.to_thread.run_sync which properly propagates contextvars, making this safe for functions that depend on context (like dependency injection). -### `gather` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/async_utils.py#L53" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `gather` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/async_utils.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -gather(awaitables: Iterable[Awaitable[T]]) -> list[T] | list[T | BaseException] +gather(*awaitables: Awaitable[T]) -> list[T] | list[T | BaseException] ``` @@ -48,25 +48,8 @@ Run awaitables concurrently and return results in order. Uses anyio TaskGroup for structured concurrency. -``awaitables`` is consumed lazily, one item at a time, right before each -is handed to the task group. Callers with a dynamic number of awaitables -should pass a generator expression (e.g. ``gather(f(x) for x in xs)``) -rather than a list or list comprehension: a list comprehension calls -every ``f(x)`` up front, creating a batch of coroutine objects before -this function even starts, whereas a generator expression creates each -coroutine only as this function's own scheduling loop asks for it. That -matters because coroutine creation and scheduling can be interrupted -between any two bytecode instructions by a synchronous signal handler -(for example pytest-timeout's SIGALRM-based per-test timeout). If that -happens while a whole batch of coroutines is sitting unscheduled, they -are silently abandoned and eventually trigger a "coroutine was never -awaited" warning attributed to whatever unrelated code happens to be -running when the garbage collector gets to them. Lazy consumption keeps -the window in which a created-but-unscheduled coroutine can exist as -small as possible. - **Args:** -- `awaitables`: Iterable of awaitables to run concurrently. +- `*awaitables`: Awaitables to run concurrently - `return_exceptions`: If True, exceptions are returned in results. If False, first exception cancels all and raises. diff --git a/docs/python-sdk/fastmcp-utilities-authorization.mdx b/docs/python-sdk/fastmcp-utilities-authorization.mdx index 0f64a0a4a..f70c44283 100644 --- a/docs/python-sdk/fastmcp-utilities-authorization.mdx +++ b/docs/python-sdk/fastmcp-utilities-authorization.mdx @@ -15,7 +15,7 @@ deny with a custom message; other exceptions are masked and treated as denial. ## Functions -### `require_scopes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L143" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `require_scopes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L50" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python require_scopes(*scopes: str) -> AuthCheck @@ -25,52 +25,7 @@ require_scopes(*scopes: str) -> AuthCheck Require all of the given OAuth scopes. -### `require_roles` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L148" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -require_roles(*roles: str) -> AuthCheck -``` - - -Require all of the given roles, read from the token's claims. - -Roles and groups are not part of OIDC, so every identity provider puts them -somewhere different: `realm_access.roles` on Keycloak, `roles` on Microsoft -Entra, `cognito:groups` on AWS Cognito, `permissions` or a namespaced custom -claim on Auth0. `extract` receives the token's claims and returns the -caller's roles, which keeps that provider-specific knowledge at the call -site instead of guessing it here. - -```python -from fastmcp.server.auth import require_roles - -keycloak = require_roles("admin", extract=lambda c: c["realm_access"]["roles"]) -cognito = require_roles("admins", extract=lambda c: c["cognito:groups"]) -``` - -A token missing the claim entirely is denied rather than treated as an -error, so `extract` may index into the claims without guarding. An -extractor returning a bare string is treated as one role, since a provider -that stores a single role as a scalar is common. - -Unlike `require_scopes`, this check cannot signal a shortfall: OAuth has no -way to request a role, so there is no `insufficient_scope` challenge to -emit. A role denial is therefore reported as a plain `AuthorizationError`, -and it suppresses any scope shortfall alongside it — a caller blocked by -their role must not be told to go obtain a scope that would not help. -Scope shortfalls are still reported normally whenever the role check -passes. - -**Args:** -- `*roles`: Roles the caller must hold. All are required (AND logic). -- `extract`: Callable mapping the token's claims to the caller's roles. - -**Raises:** -- `ValueError`: If no roles are given, which would allow any authenticated -caller and is more likely a mistake than an intent. - - -### `restrict_tag` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L197" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `restrict_tag` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L62" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python restrict_tag(tag: str) -> AuthCheck @@ -80,62 +35,14 @@ restrict_tag(tag: str) -> AuthCheck Require scopes when the accessed component has a specific tag. -### `scope_requirements` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L202" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -scope_requirements(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> list[str] | None -``` - - -Scopes a check list requires but the token lacks, without running it. - -Returns ``None`` when the list contains any opaque (non-scope) check. Such a -check might deny for a reason unrelated to scopes, and evaluating it here -would run authorization logic — with whatever side effects it carries — -outside its normal place in the chain. Since its verdict is unknown, its -siblings' scopes must not be disclosed either, so the whole list is withheld. - -When every check is scope-aware, the result is their combined shortfall, -computed purely from the token and component (an empty list means the list is -already satisfied). This lets a shortfall be aggregated across authorization -layers without evaluating anything that would otherwise be skipped. - - -### `run_auth_checks_with_shortfall` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L253" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -run_auth_checks_with_shortfall(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> tuple[bool, list[str]] -``` - - -Run auth checks with AND logic, classifying the denial cause. - -Returns ``(authorized, missing_scopes)``. ``missing_scopes`` names every -scope the caller must obtain to satisfy *all* scope requirements at once: -the union of the shortfalls across every scope-aware check, not just the -first one to fail. Reporting only the first would strand a caller in a -step-up loop — it obtains that scope, retries, and is denied again for the -next — so the union is what makes a single re-authorization converge. - -The challenge is withheld entirely (an empty list, which the caller surfaces -as a plain ``AuthorizationError``) unless every non-scope check passes. A -custom policy denial — a tenant check, say — must never be reported as an -``insufficient_scope`` shortfall, and must never name the scopes of a -component the caller could not otherwise reach. To guarantee that, the -opaque checks are all evaluated before any scope is disclosed; a shortfall -is only reported once they have all passed. - -An ``AuthorizationError`` raised by a check propagates unchanged. - - -### `run_auth_checks` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L304" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `run_auth_checks` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/authorization.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool ``` -Run auth checks with AND logic, stopping at the first failure. +Run auth checks with AND logic. ## Classes diff --git a/docs/python-sdk/fastmcp-utilities-components.mdx b/docs/python-sdk/fastmcp-utilities-components.mdx index eb71e0a88..7bb360a63 100644 --- a/docs/python-sdk/fastmcp-utilities-components.mdx +++ b/docs/python-sdk/fastmcp-utilities-components.mdx @@ -7,7 +7,7 @@ sidebarTitle: components ## Functions -### `get_fastmcp_metadata` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L22" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `get_fastmcp_metadata` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L26" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python get_fastmcp_metadata(meta: dict[str, Any] | None) -> FastMCPMeta @@ -22,9 +22,9 @@ namespace for compatibility with older FastMCP servers. ## Classes -### `FastMCPMeta` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L16" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `FastMCPMeta` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L20" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> -### `FastMCPComponent` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L70" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `FastMCPComponent` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L74" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Base class for FastMCP tools, prompts, resources, and resource templates. @@ -114,7 +114,53 @@ copy(self) -> Self Create a copy of the component. -#### `get_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L227" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `register_with_docket` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L227" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> + +```python +register_with_docket(self, docket: Docket) -> None +``` + +Register this component with docket for background execution. + +No-ops if task_config.mode is "forbidden". Subclasses override to +register their callable (self.run, self.read, self.render, or self.fn). + + +#### `coerce_task_arguments` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L235" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> + +```python +coerce_task_arguments(self, arguments: dict[str, Any]) -> dict[str, Any] +``` + +Validate and coerce task arguments before any task state is created. + +Called by ``submit_to_docket`` up front, so invalid inputs raise before +the task's Redis metadata and initial status notification exist — +otherwise a coercion failure during queueing would orphan a task the +client has already observed. The base implementation is a no-op; +components that splat arguments into a typed Python callable (e.g. +``FunctionTool``) override this to mirror the synchronous validation +path. + + +#### `add_to_docket` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L248" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> + +```python +add_to_docket(self, docket: Docket, *args: Any, **kwargs: Any) -> Execution +``` + +Schedule this component for background execution via docket. + +Subclasses override this to handle their specific calling conventions: +- Tool: add_to_docket(docket, arguments: dict, **kwargs) +- Resource: add_to_docket(docket, **kwargs) +- ResourceTemplate: add_to_docket(docket, params: dict, **kwargs) +- Prompt: add_to_docket(docket, arguments: dict | None, **kwargs) + +The **kwargs are passed through to docket.add() (e.g., key=task_key). + + +#### `get_span_attributes` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/components.py#L270" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-utilities-docstring_parsing.mdx b/docs/python-sdk/fastmcp-utilities-docstring_parsing.mdx index bceb0256e..b8cacc823 100644 --- a/docs/python-sdk/fastmcp-utilities-docstring_parsing.mdx +++ b/docs/python-sdk/fastmcp-utilities-docstring_parsing.mdx @@ -16,7 +16,7 @@ callers. ## Functions -### `parse_docstring` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/docstring_parsing.py#L33" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `parse_docstring` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/docstring_parsing.py#L35" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring @@ -32,7 +32,7 @@ docstring as the description with no parameter descriptions. ## Classes -### `ParsedDocstring` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/docstring_parsing.py#L26" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `ParsedDocstring` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/docstring_parsing.py#L28" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> The extracted description and per-parameter descriptions from a docstring. diff --git a/docs/python-sdk/fastmcp-utilities-exceptions.mdx b/docs/python-sdk/fastmcp-utilities-exceptions.mdx index 129ad5a67..563c9a176 100644 --- a/docs/python-sdk/fastmcp-utilities-exceptions.mdx +++ b/docs/python-sdk/fastmcp-utilities-exceptions.mdx @@ -7,53 +7,13 @@ sidebarTitle: exceptions ## Functions -### `is_http_status_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L19" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_http_status_error(exc: BaseException) -> bool -``` - - -Return whether an exception is an httpx2 or legacy-httpx status error. - - -### `get_http_status_code` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L26" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -get_http_status_code(exc: BaseException) -> int | None -``` - - -Return the response status code from a recognized HTTP status error. - - -### `is_timeout_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_timeout_error(exc: BaseException) -> bool -``` - - -Return whether an exception is an httpx2 or legacy-httpx timeout. - - -### `is_request_error` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L41" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_request_error(exc: BaseException) -> bool -``` - - -Return whether an exception is an httpx2 or legacy-httpx request error. - - -### `iter_exc` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L48" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `iter_exc` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L12" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python iter_exc(group: BaseExceptionGroup) ``` -### `get_catch_handlers` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L76" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `get_catch_handlers` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/exceptions.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python get_catch_handlers() -> Mapping[type[BaseException] | Iterable[type[BaseException]], Callable[[BaseExceptionGroup[Any]], Any]] diff --git a/docs/python-sdk/fastmcp-utilities-inspect.mdx b/docs/python-sdk/fastmcp-utilities-inspect.mdx index d2aca51d1..578a936b1 100644 --- a/docs/python-sdk/fastmcp-utilities-inspect.mdx +++ b/docs/python-sdk/fastmcp-utilities-inspect.mdx @@ -26,10 +26,10 @@ Extract information from a FastMCP v2.x instance. - FastMCPInfo dataclass containing the extracted information -### `inspect_fastmcp_v1` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L251" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `inspect_fastmcp_v1` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L236" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -inspect_fastmcp_v1(mcp: SDKServer) -> FastMCPInfo +inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo ``` @@ -42,10 +42,10 @@ Extract information from a FastMCP v1.x instance using a Client. - FastMCPInfo dataclass containing the extracted information -### `inspect_fastmcp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L413" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `inspect_fastmcp` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L378" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -inspect_fastmcp(mcp: FastMCP[Any] | SDKServer) -> FastMCPInfo +inspect_fastmcp(mcp: FastMCP[Any] | FastMCP1x) -> FastMCPInfo ``` @@ -61,7 +61,7 @@ and uses the appropriate extraction method. - FastMCPInfo dataclass containing the extracted information -### `format_fastmcp_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L438" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `format_fastmcp_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L403" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python format_fastmcp_info(info: FastMCPInfo) -> bytes @@ -73,10 +73,10 @@ Format FastMCPInfo as FastMCP-specific JSON. This includes FastMCP-specific fields like tags, enabled, annotations, etc. -### `format_mcp_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L467" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `format_mcp_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L432" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -format_mcp_info(mcp: FastMCP[Any] | SDKServer) -> bytes +format_mcp_info(mcp: FastMCP[Any] | FastMCP1x) -> bytes ``` @@ -86,10 +86,10 @@ Uses Client to get the standard MCP protocol format with camelCase fields. Includes version metadata at the top level. -### `format_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L502" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `format_info` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L465" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -format_info(mcp: FastMCP[Any] | SDKServer, format: InspectFormat | Literal['fastmcp', 'mcp'], info: FastMCPInfo | None = None) -> bytes +format_info(mcp: FastMCP[Any] | FastMCP1x, format: InspectFormat | Literal['fastmcp', 'mcp'], info: FastMCPInfo | None = None) -> bytes ``` @@ -136,7 +136,7 @@ Information about a resource template. Information extracted from a FastMCP instance. -### `InspectFormat` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L431" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `InspectFormat` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/inspect.py#L396" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Output format for inspect command. diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index 654108a63..43e99691d 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -7,17 +7,7 @@ sidebarTitle: json_schema ## Functions -### `replace_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L7" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -replace_refs(*args: Any, **kwargs: Any) -> Any -``` - - -Call jsonref lazily while preserving the module's patchable boundary. - - -### `require_discriminator_property` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L154" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `require_discriminator_property` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L116" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python require_discriminator_property(schema: dict[str, Any]) -> dict[str, Any] @@ -34,7 +24,7 @@ model with ``union_tag_not_found``. No-op if there is no string ``propertyName``. -### `dereference_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `dereference_refs` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L147" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python dereference_refs(schema: dict[str, Any]) -> dict[str, Any] @@ -67,7 +57,7 @@ schemas from untrusted servers. - when no longer needed -### `resolve_root_ref` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L336" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `resolve_root_ref` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L294" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any] @@ -89,7 +79,7 @@ the referenced definition while preserving $defs for nested references. - if no resolution is needed -### `compress_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L750" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `compress_schema` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/json_schema.py#L688" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-utilities-logging.mdx b/docs/python-sdk/fastmcp-utilities-logging.mdx index 4dde3d509..f3b58bf7e 100644 --- a/docs/python-sdk/fastmcp-utilities-logging.mdx +++ b/docs/python-sdk/fastmcp-utilities-logging.mdx @@ -10,7 +10,7 @@ Logging utilities for FastMCP. ## Functions -### `get_logger` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L27" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `get_logger` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L14" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python get_logger(name: str) -> logging.Logger @@ -26,7 +26,7 @@ Get a logger nested under FastMCP namespace. - a configured logger instance -### `configure_logging` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `configure_logging` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L29" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python configure_logging(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | int = 'INFO', logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) -> None @@ -41,7 +41,7 @@ Configure logging for FastMCP. - `rich_kwargs`: the parameters to use for creating RichHandler -### `temporary_log_level` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L127" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `temporary_log_level` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/logging.py#L117" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python temporary_log_level(level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) diff --git a/docs/python-sdk/fastmcp-utilities-prefab.mdx b/docs/python-sdk/fastmcp-utilities-prefab.mdx deleted file mode 100644 index b03d7b185..000000000 --- a/docs/python-sdk/fastmcp-utilities-prefab.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: prefab -sidebarTitle: prefab ---- - -# `fastmcp.utilities.prefab` - - -Lazy helpers for FastMCP's optional Prefab UI integration. - -## Functions - -### `prefab_available` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L12" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -prefab_available() -> bool -``` - - -Return whether Prefab UI is installed without importing it. - - -### `is_prefab_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_prefab_type(candidate: Any) -> bool -``` - - -Return whether a type is a Prefab app or component type. - - -### `is_prefab_app` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_prefab_app(value: Any) -> bool -``` - - -Return whether a value is a Prefab app. - - -### `is_prefab_component` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -is_prefab_component(value: Any) -> bool -``` - - -Return whether a value is a Prefab component. - - -### `prefab_app_from_component` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/prefab.py#L69" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -prefab_app_from_component(component: Any) -> Any -``` - - -Wrap a Prefab component in a Prefab app. - diff --git a/docs/python-sdk/fastmcp-utilities-tasks.mdx b/docs/python-sdk/fastmcp-utilities-tasks.mdx index 7a520bb04..ed455626e 100644 --- a/docs/python-sdk/fastmcp-utilities-tasks.mdx +++ b/docs/python-sdk/fastmcp-utilities-tasks.mdx @@ -10,7 +10,7 @@ Task configuration primitives for FastMCP components. ## Classes -### `TaskMeta` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L29" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `TaskMeta` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L22" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Metadata for task-augmented execution requests. @@ -20,7 +20,7 @@ Metadata for task-augmented execution requests. - `fn_key`: Docket routing key. Auto-derived from component name if None. -### `TaskConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L42" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `TaskConfig` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L35" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Configuration for MCP background task execution. @@ -34,7 +34,7 @@ Controls how a component handles task-augmented requests: **Methods:** -#### `from_bool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L56" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `from_bool` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L49" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python from_bool(cls, value: bool) -> TaskConfig @@ -43,7 +43,7 @@ from_bool(cls, value: bool) -> TaskConfig Convert a boolean task flag to a TaskConfig. -#### `supports_tasks` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `supports_tasks` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L53" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python supports_tasks(self) -> bool @@ -52,7 +52,7 @@ supports_tasks(self) -> bool Check if this component supports task execution. -#### `validate_function` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L64" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `validate_function` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tasks.py#L57" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python validate_function(self, fn: Callable[..., Any], name: str) -> None diff --git a/docs/python-sdk/fastmcp-utilities-tests.mdx b/docs/python-sdk/fastmcp-utilities-tests.mdx index 6c1ce3e92..ca779b9a2 100644 --- a/docs/python-sdk/fastmcp-utilities-tests.mdx +++ b/docs/python-sdk/fastmcp-utilities-tests.mdx @@ -7,7 +7,7 @@ sidebarTitle: tests ## Functions -### `temporary_settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `temporary_settings` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L24" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python temporary_settings(**kwargs: Any) @@ -20,7 +20,7 @@ Temporarily override FastMCP setting values. - `**kwargs`: The settings to override, including nested settings. -### `run_server_in_process` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L87" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `run_server_in_process` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L75" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python run_server_in_process(server_fn: Callable[..., None], *args: Any, **kwargs: Any) -> Generator[str, None, None] @@ -43,20 +43,18 @@ not pickleable, so we need a function that creates and runs one. - The server URL. -### `run_server_async` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L175" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `run_server_async` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L143" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python run_server_async(server: FastMCP, port: int | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http', path: str = '/mcp', host: str = '127.0.0.1') -> AsyncGenerator[str, None] ``` -Start a FastMCP server on a real port as an asyncio task. +Start a FastMCP server as an asyncio task for in-process async testing. -This runs a real uvicorn server in the current process, bound to a real TCP port, -and yields its URL. Use it when the behaviour under test is genuinely about the -network — real sockets, TLS, or a server that must be reachable by something other -than an in-process client. Otherwise prefer `asgi_client` or `asgi_server`, which -exercise the same HTTP stack without binding a port. +This is the recommended way to test FastMCP servers. It runs the server +as an async task in the same process, eliminating subprocess coordination, +sleeps, and cleanup issues. **Args:** - `server`: FastMCP server instance @@ -66,124 +64,9 @@ exercise the same HTTP stack without binding a port. - `host`: Host to bind to (default\: "127.0.0.1") -### `asgi_server` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L335" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -asgi_server(server: FastMCP, transport: Literal['http', 'streamable-http', 'sse'] = 'http', path: str | None = None, **http_app_kwargs: Any) -> AsyncGenerator[ASGIServer, None] -``` - - -Serve a FastMCP server's HTTP app in-process, with no socket and no uvicorn. - -This is the fastest way to test a FastMCP server over HTTP. The server's real -Starlette app is built with `http_app()` and its lifespan is started, then every -request is dispatched directly into the app on the current event loop. That skips -port binding, uvicorn startup and connection setup entirely, while still exercising -the full HTTP stack: middleware, authentication, session management and SSE -streaming all run exactly as they do in production. - -Use this as a fixture when several tests share one server but each needs its own -client. For a single test, `asgi_client` hands you a connected client in one step. - -**Args:** -- `server`: FastMCP server instance. -- `transport`: Transport type ("http", "streamable-http", or "sse"). -- `path`: URL path for the server (defaults to "/mcp", or "/sse" for SSE). -- `**http_app_kwargs`: Additional arguments forwarded to `server.http_app()`. - - -### `asgi_client` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L409" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -asgi_client(server: FastMCP, transport: Literal['http', 'streamable-http', 'sse'] = 'http', path: str | None = None, **client_kwargs: Any) -> AsyncGenerator[Client, None] -``` - - -Serve a FastMCP server over HTTP in-process and yield a connected `Client`. - -This is the shortest path to testing a server over a real HTTP stack. The server's -Starlette app is built and started, and requests are dispatched straight into it on -the current event loop — no port, no uvicorn, no subprocess — but middleware, -authentication, session management and SSE streaming all behave as in production. - -Reach for `asgi_server` instead when a fixture must serve several tests that each -build their own client, or when a test needs raw HTTP access to the app. - -**Args:** -- `server`: FastMCP server instance. -- `transport`: Transport type ("http", "streamable-http", or "sse"). -- `path`: URL path for the server (defaults to "/mcp", or "/sse" for SSE). -- `headers`: HTTP headers to send with every request. -- `auth`: Client authentication, as accepted by the HTTP transports. -- `**client_kwargs`: Additional arguments forwarded to `Client`. - - ## Classes -### `ASGIServer` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L256" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - - -A FastMCP server's real HTTP app, reachable in-process with no sockets. - -Yielded by `asgi_server`. The `url` looks like an ordinary server URL and the app -behind it is the genuine article — auth middleware, session manager, SSE framing and -redirects all run — but every request is dispatched straight into the ASGI -application on the current event loop. - -Because nothing is listening on the network, a plain `httpx2.AsyncClient()` cannot -reach this server. Use `client()` for a FastMCP client, `http_client()` for raw HTTP -assertions, and `transport()` when you need to build the client transport yourself. - - -**Methods:** - -#### `http_client` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L273" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -http_client(self, headers: dict[str, str] | None = None, timeout: httpx2.Timeout | None = None, auth: httpx2.Auth | None = None, **kwargs: Any) -> httpx2.AsyncClient -``` - -An `httpx2.AsyncClient` bound to the in-process app, for raw HTTP assertions. - -Relative URLs resolve against the server's base URL, and absolute URLs on the -same origin work too, so `client.get(f"{server.url}/health")` reads the same as -it would against a real server. - -The signature matches `McpHttpClientFactory`, so this method can also be handed -to anything that takes an `httpx_client_factory`. - - -#### `transport` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L302" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -transport(self, **kwargs: Any) -> StreamableHttpTransport | SSETransport -``` - -A FastMCP client transport wired to the in-process app. - -Accepts the same keyword arguments as the underlying transport (`headers`, -`auth`, ...); `httpx_client_factory` is supplied automatically. - - -#### `client` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L313" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> - -```python -client(self, **client_kwargs: Any) -> Client -``` - -An unconnected FastMCP `Client` pointed at the in-process app. - -`headers` and `auth` configure the underlying HTTP transport; every other -keyword argument is passed to `Client` (`timeout`, `elicitation_handler`, ...). -Use it as a context manager, exactly like any other client. - -**Args:** -- `headers`: HTTP headers to send with every request. -- `auth`: Client authentication, as accepted by the HTTP transports. -- `**client_kwargs`: Additional arguments forwarded to `Client`. - - -### `HeadlessOAuth` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L464" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `HeadlessOAuth` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L225" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> OAuth provider that bypasses browser interaction for testing. @@ -194,7 +77,7 @@ instead of opening a browser and running a callback server. Useful for automated **Methods:** -#### `redirect_handler` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L477" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `redirect_handler` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L238" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python redirect_handler(self, authorization_url: str) -> None @@ -203,11 +86,11 @@ redirect_handler(self, authorization_url: str) -> None Make HTTP request to authorization URL and store response for callback handler. -#### `callback_handler` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L483" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `callback_handler` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/tests.py#L244" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -callback_handler(self) -> AuthorizationCodeResult +callback_handler(self) -> tuple[str, str | None] ``` -Parse stored response and return the authorization code result. +Parse stored response and return (auth_code, state). diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx index 6b8ca2ce8..7f1b03022 100644 --- a/docs/python-sdk/fastmcp-utilities-types.mdx +++ b/docs/python-sdk/fastmcp-utilities-types.mdx @@ -10,13 +10,13 @@ Common types used across FastMCP. ## Functions -### `get_fn_name` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L39" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `get_fn_name` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python get_fn_name(fn: Callable[..., Any]) -> str ``` -### `get_cached_typeadapter` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L50" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `get_cached_typeadapter` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L45" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python get_cached_typeadapter(cls: T) -> TypeAdapter[T] @@ -29,7 +29,7 @@ However, this isn't feasible for user-generated functions. Instead, we use a cache to minimize the cost of creating them as much as possible. -### `issubclass_safe` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `issubclass_safe` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L123" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python issubclass_safe(cls: type, base: type) -> bool @@ -39,7 +39,7 @@ issubclass_safe(cls: type, base: type) -> bool Check if cls is a subclass of base, even if cls is a type variable. -### `is_class_member_of_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L138" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `is_class_member_of_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L133" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python is_class_member_of_type(cls: Any, base: type) -> bool @@ -52,7 +52,7 @@ Base can be a type, a UnionType, or an Annotated type. Generic types are not considered members (e.g. T is not a member of list\[T]). -### `find_kwarg_by_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L160" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `find_kwarg_by_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L155" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None @@ -64,7 +64,7 @@ Find the name of the kwarg that is of type kwarg_type. Includes union types that contain the kwarg_type, as well as Annotated types. -### `create_function_without_params` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L186" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `create_function_without_params` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L181" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python create_function_without_params(fn: Callable[..., Any], exclude_params: list[str]) -> Callable[..., Any] @@ -77,7 +77,7 @@ This is used to exclude parameters from type adapter processing when they can't The excluded parameters are removed from the function's __annotations__ dictionary. -### `replace_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L469" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `replace_type` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L454" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python replace_type(type_, type_map: dict[type, type]) @@ -105,13 +105,13 @@ list[list[str]] ## Classes -### `FastMCPBaseModel` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L43" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `FastMCPBaseModel` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L38" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Base model for FastMCP models. -### `Image` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L243" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `Image` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L238" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Helper class for returning images from tools. @@ -119,16 +119,16 @@ Helper class for returning images from tools. **Methods:** -#### `to_image_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L294" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `to_image_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L289" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -to_image_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.ImageContent +to_image_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.ImageContent ``` Convert to MCP ImageContent. -#### `to_data_uri` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L309" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `to_data_uri` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L304" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python to_data_uri(self, mime_type: str | None = None) -> str @@ -137,7 +137,7 @@ to_data_uri(self, mime_type: str | None = None) -> str Get image as a data URI. -### `Audio` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L315" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `Audio` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L310" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Helper class for returning audio from tools. @@ -145,13 +145,13 @@ Helper class for returning audio from tools. **Methods:** -#### `to_audio_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L355" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `to_audio_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L347" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.AudioContent +to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.AudioContent ``` -### `File` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L376" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `File` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L368" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> Helper class for returning file data from tools. @@ -159,10 +159,10 @@ Helper class for returning file data from tools. **Methods:** -#### `to_resource_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L415" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +#### `to_resource_content` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L407" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> ```python -to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.EmbeddedResource +to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.EmbeddedResource ``` -### `ContextSamplingFallbackProtocol` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L505" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> +### `ContextSamplingFallbackProtocol` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L490" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup> diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx index a948a647c..d37c57f36 100644 --- a/docs/servers/auth/authentication.mdx +++ b/docs/servers/auth/authentication.mdx @@ -189,19 +189,11 @@ from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -upstream_verifier = JWTVerifier( - jwks_uri="https://login.example.com/.well-known/jwks.json", - issuer="https://login.example.com", - audience="my-app", -) - auth = MultiAuth( server=OAuthProxy( - upstream_authorization_endpoint="https://login.example.com/oauth/authorize", - upstream_token_endpoint="https://login.example.com/oauth/token", - upstream_client_id="my-app", - upstream_client_secret="secret", - token_verifier=upstream_verifier, + issuer_url="https://login.example.com/...", + client_id="my-app", + client_secret="secret", base_url="https://my-server.com", ), verifiers=[ diff --git a/docs/servers/auth/multi-auth.mdx b/docs/servers/auth/multi-auth.mdx index 3675c92a6..ba54d25ab 100644 --- a/docs/servers/auth/multi-auth.mdx +++ b/docs/servers/auth/multi-auth.mdx @@ -22,19 +22,11 @@ from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -upstream_verifier = JWTVerifier( - jwks_uri="https://login.example.com/.well-known/jwks.json", - issuer="https://login.example.com", - audience="my-app", -) - auth = MultiAuth( server=OAuthProxy( - upstream_authorization_endpoint="https://login.example.com/oauth/authorize", - upstream_token_endpoint="https://login.example.com/oauth/token", - upstream_client_id="my-app", - upstream_client_secret="secret", - token_verifier=upstream_verifier, + issuer_url="https://login.example.com/...", + client_id="my-app", + client_secret="secret", base_url="https://my-server.com", ), verifiers=[ diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 03727ec87..e35f244eb 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -114,7 +114,7 @@ mcp = FastMCP(name="My Server", auth=auth) <ParamField body="base_url" type="AnyHttpUrl | str" required> Public URL where OAuth endpoints will be accessible, **including any mount path** (e.g., `https://your-server.com/api`). - This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to give the server an OAuth identity that differs from where its endpoints are mounted (typically the root level). + This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level). </ParamField> <ParamField body="resource_base_url" type="AnyHttpUrl | str | None"> @@ -135,8 +135,6 @@ mcp = FastMCP(name="My Server", auth=auth) <ParamField body="issuer_url" type="AnyHttpUrl | str | None"> Issuer URL for OAuth authorization server metadata (defaults to `base_url`). - `issuer_url` is the server's OAuth identity: it is the `issuer` field of the authorization server metadata, the `iss` claim of the tokens the proxy mints, and the RFC 9207 `iss` parameter on authorization responses. `base_url` remains the location of the endpoints, so `authorization_endpoint`, `token_endpoint`, and the rest of the metadata still point at `base_url` where the routes are actually mounted. - When `issuer_url` has a path component (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`. **Default behavior (recommended for most cases):** @@ -206,11 +204,9 @@ mcp = FastMCP(name="My Server", auth=auth) </ParamField> <ParamField body="valid_scopes" type="list[str] | None"> - The complete set of scopes clients are allowed to request — the full set of - available scopes (a superset of `required_scopes`). These are advertised to - clients through the `/.well-known` endpoints and enforced at Dynamic Client - Registration. Defaults to `required_scopes` from your TokenVerifier if not - specified. + List of all possible valid scopes for the OAuth provider. These are advertised + to clients through the `/.well-known` endpoints. Defaults to `required_scopes` + from your TokenVerifier if not specified. </ParamField> <ParamField body="extra_authorize_params" type="dict[str, str] | None"> @@ -283,11 +279,10 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) <ParamField body="jwt_signing_key" type="str | bytes | None"> <VersionBadge version="2.13.0" /> - Secret used to sign FastMCP JWT tokens issued to clients. How the key is derived depends on what you pass: + Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF. - - **`bytes`** are used as-is, with no stretching, so supply at least 32 bytes of high-entropy key material. With the default file-backed client storage, the bytes must also decode as UTF-8; use `secrets.token_urlsafe(32).encode()` instead of raw `secrets.token_bytes()`, or configure `client_storage` explicitly. - - **A string** is stretched into a 32-byte key with PBKDF2 (1,000,000 iterations), since a supplied string may be low-entropy. Strings shorter than 12 characters also log a warning. - - **`None`** (the default) derives a 32-byte key from the upstream client secret using HKDF. + **Default behavior (`None`):** + Derives a 32-byte key using PBKDF2 from the upstream client secret. **For production:** Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the key derived from the upstream client secret. This allows you to manage keys securely in cloud environments, allows keys to work across multiple instances, and allows you to rotate keys without losing client registrations. @@ -315,10 +310,8 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) **`"remember"` — silent consent on return:** Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class. - **`"external"` — externally managed:** - Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections. - - Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections. + **`"external"` — delegate to upstream:** + Skip the built-in consent page; consent is collected by the upstream IdP or a custom login page referenced via `upstream_authorization_endpoint`. No security warning is logged. **`False` — disable entirely:** Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing. @@ -338,7 +331,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) ``` <Warning> - Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow. + Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients. </Warning> </ParamField> @@ -592,27 +585,6 @@ auth = OAuthProxy( Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use. -### Application Type (Web vs. Native) - -<VersionBadge version="4.0.0" /> - -During Dynamic Client Registration, a client may declare an `application_type` (per RFC 7591 and SEP-837) that governs which redirect URIs it is allowed to use. The OAuth proxy honors this field both at registration and when authorizing a redirect. - -`application_type` defaults to `"native"` because MCP clients typically run locally and register loopback callbacks. Clients that omit the field keep the permissive behavior described above. A client that explicitly registers as `"web"` is held to the stricter browser-app rules. - -Loopback covers the whole reserved range in both the address and name forms: every address in `127.0.0.0/8`, `::1`, and — per RFC 6761 — the name `localhost` along with any subdomain of it, such as `app.localhost`. The absolute (trailing-dot) spellings `localhost.` and `127.0.0.1.` are treated identically. A name that merely contains `localhost` as a label of a registrable domain, like `localhost.example.com`, is an ordinary public host and is not treated as loopback. - -| `application_type` | Allowed redirect URIs | -| ------------------ | --------------------- | -| `"native"` (default) | `https` URLs; app and private-use schemes (`vscode://callback`, `com.example.app:/callback`, `myapp://callback`, `urn:ietf:wg:oauth:2.0:oob`); and loopback `http` (`http://127.0.0.1`, any address in `127.0.0.0/8`, `http://localhost`, subdomains such as `http://app.localhost`, `http://[::1]`, any port) | -| `"web"` | `https` on a non-loopback host only | - -Web clients must register a non-loopback `https` callback — that is the restriction SEP-837 asks for, and a web client that registers no redirect URI at all is refused, since it could never complete an authorization. Native clients keep the full range of schemes their platforms use; the only new limit is that cleartext `http` must target a loopback host, per RFC 8252 §7.3. - -Both application types always reject unsafe browser schemes (`javascript:`, `data:`, `file:`, `vbscript:`). FastMCP does not otherwise filter a native client's scheme: there is no reliable way to tell an app-dispatch scheme from a network transport, since the IANA registry lists `vscode:` alongside `coap:` and `smb:`, so any such filter would reject callbacks that real MCP clients depend on. - -A redirect URI that violates the declared type is refused during registration with a `RegistrationError` (`invalid_redirect_uri`). For example, a `"web"` client that registers `http://localhost:12345/callback` is rejected, since web clients must use a non-loopback `https` callback. Configure remote, browser-based clients as `application_type="web"` and give them an `https` callback URL. - ## CIMD Support <VersionBadge version="3.0.0" /> @@ -681,83 +653,6 @@ auth = OAuthProxy( ) ``` -## Identity Assertion (SEP-990) - -<VersionBadge version="4.0.0" /> - -<Tip> -Identity assertion is a beta feature. The API may change in a future release. -</Tip> - -Identity assertion enables an enterprise "on-behalf-of" flow. A corporate identity provider (Okta, Microsoft Entra, etc.) issues an *ID-JAG* — a signed JWT that asserts an employee's identity to a specific MCP authorization server. The client presents that ID-JAG at the token endpoint using the RFC 7523 `jwt-bearer` grant, and the proxy validates it and mints a short-lived access token for the asserted user. No refresh token is issued: the identity provider controls session lifetime, and the client re-exchanges a fresh ID-JAG when its access token expires. This lets a workforce reach your MCP server with corporate-managed identity and centralized revocation, without each user running an interactive browser login. - -To enable it, pass an `IdentityAssertion` configuration listing the issuers you trust: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import OAuthProxy, IdentityAssertion - -auth = OAuthProxy( - upstream_authorization_endpoint="https://accounts.example.com/authorize", - upstream_token_endpoint="https://accounts.example.com/token", - upstream_client_id="your-client-id", - upstream_client_secret="your-client-secret", - base_url="https://your-server.com", - identity_assertion=IdentityAssertion( - trusted_issuers=["https://login.acme-corp.com"], - ), -) - -mcp = FastMCP("Internal API", auth=auth) - -@mcp.tool -def whoami() -> str: - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - return token.subject or "unknown" -``` - -When identity assertion is configured, the proxy advertises the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type and the `urn:ietf:params:oauth:grant-profile:id-jag` grant profile in its authorization server metadata, so compatible clients can discover the capability. When it is not configured, the grant is rejected as unsupported. - -### How Validation Works - -For each ID-JAG presented at the token endpoint, the proxy checks that: - -- the JOSE header `typ` is `oauth-id-jag+jwt`; -- the `iss` claim is one of the configured `trusted_issuers`; -- the signature verifies against the issuer's published keys; -- the `aud` claim identifies this authorization server — configure your identity provider to mint assertions whose `aud` is the `issuer` value published at `/.well-known/oauth-authorization-server`, which is your `issuer_url` when you set one and your `base_url` otherwise; -- the signed `client_id` claim matches the client presenting the assertion — an assertion the IdP minted for one client cannot be redeemed by another; -- the signed `resource` claim names this server — an assertion minted for a different MCP server behind the same IdP is rejected; -- `exp` (and `iat`/`nbf`, when present) place the assertion within a short lifetime and its validity window; and -- the `jti` has not been seen before, preventing replay. - -The issuer's signing keys are discovered automatically via OIDC (`{issuer}/.well-known/openid-configuration`). For issuers that do not publish a discovery document, provide the JWKS URI explicitly per issuer: - -```python -identity_assertion=IdentityAssertion( - trusted_issuers=["https://login.acme-corp.com"], - jwks_uris={"https://login.acme-corp.com": "https://login.acme-corp.com/keys"}, -) -``` - -Verification assumes `RS256` unless the issuer signs with another algorithm, in which case set `algorithm` explicitly (any asymmetric JWS algorithm — `RS*`, `PS*`, or `ES*` — since assertions are verified against a published JWKS, not a shared secret). When trusted issuers use different algorithms, override per issuer with `algorithms`, keyed the same way as `jwks_uris`: - -```python -identity_assertion=IdentityAssertion( - trusted_issuers=["https://login.acme-corp.com", "https://sso.other-corp.com"], - algorithm="ES256", - algorithms={"https://sso.other-corp.com": "RS256"}, -) -``` - -The subject asserted in the ID-JAG flows into the normal FastMCP auth context. Tools read it through `get_access_token()` exactly as they would for any other token, because the proxy issues the access token through its own token factory. - -<Warning> -Replay protection is per-process. Each server process tracks seen `jti` values in memory, so a horizontally-scaled deployment running multiple workers or replicas could accept the same assertion once per process. The same applies to revocation of ID-JAG access tokens: they are self-contained, so revocation is tracked in-process until the token's (short, 5-minute default) natural expiry. For deployments that require strict single-use enforcement across replicas, configure sticky routing so a given client's requests reach the same process, or place a shared store in front of the token endpoint. This mirrors the posture of CIMD `private_key_jwt` replay protection, which is also per-process. -</Warning> - ## Security ### Key and Storage Management @@ -766,7 +661,8 @@ Replay protection is per-process. Each server process tracks seen `jti` values i The OAuth proxy requires cryptographic keys for JWT signing and storage encryption, plus persistent storage to maintain valid tokens across server restarts. **Default behavior (appropriate for development only):** -On every platform, FastMCP deterministically derives `jwt_signing_key` from `upstream_client_secret` using HKDF, and storage defaults to an encrypted disk store in your platform's data directory (derived from `platformdirs`). Tokens survive server restarts as long as `upstream_client_secret` doesn't change. This is **only** suitable for development and local testing. +- **Mac/Windows**: FastMCP automatically generates keys and stores them in your system keyring. Storage defaults to disk. Tokens survive server restarts. This is **only** suitable for development and local testing. +- **Linux**: Keys are ephemeral (random salt at startup). Storage defaults to memory. Tokens become invalid on server restart. **For production:** Configure the following parameters together: provide a unique `jwt_signing_key` (for signing FastMCP JWTs), and a shared `client_storage` backend (for storing tokens). Both are required for production deployments. Use a network-accessible storage backend like Redis or DynamoDB rather than local disk storage. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** (see the `client_storage` parameter documentation above for examples). The keys accept any secret string and derive proper cryptographic keys using HKDF. See [OAuth Token Security](/deployment/http#oauth-token-security) and [Storage Backends](/servers/storage-backends) for complete production setup. diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index be4bcb22d..fde747e2b 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -132,15 +132,6 @@ These patterns apply to MCP client loopback redirects. Configure the upstream OA </ParamField> -<ParamField body="valid_scopes" type="list[str] | None"> - The complete set of scopes clients are allowed to request — the full set of - available scopes (a superset of `required_scopes`). These are advertised to - clients through the `/.well-known` endpoints (as `scopes_supported`) and - enforced at Dynamic Client Registration: a client registering with a scope - outside this set is rejected. Defaults to `required_scopes` from your token - verifier if not specified. -</ParamField> - <ParamField body="token_endpoint_auth_method" type="str | None"> Token endpoint authentication method for the upstream OAuth server. Controls how the proxy authenticates when exchanging authorization codes and refresh tokens with the upstream provider. - `"client_secret_basic"`: Send credentials in Authorization header (most common) @@ -155,13 +146,14 @@ Set this if your provider requires a specific authentication method and the defa <ParamField body="jwt_signing_key" type="str | bytes | None"> <VersionBadge version="2.13.0" /> - Secret used to sign FastMCP JWT tokens issued to clients. **`bytes`** are used as-is, with no stretching, so supply at least 32 bytes of high-entropy key material. With the default file-backed client storage, the bytes must also decode as UTF-8; use `secrets.token_urlsafe(32).encode()` instead of raw `secrets.token_bytes()`, or configure `client_storage` explicitly. **A string** is stretched into a 32-byte key with PBKDF2 (1,000,000 iterations), since a supplied string may be low-entropy. + Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF. **Default behavior (`None`):** - The key is deterministically derived from `client_secret` using HKDF, on every platform. Because the derivation is deterministic, the same key is produced across restarts as long as `client_secret` doesn't change, so tokens remain valid without any extra configuration. This convenience makes it **only** suitable for development and local testing. + - **Mac/Windows**: Auto-managed via system keyring. Keys are generated once and persisted, surviving server restarts with zero configuration. Keys are automatically derived from server attributes, so this approach, while convenient, is **only** suitable for development and local testing. For production, you must provide an explicit secret. + - **Linux**: Ephemeral (random salt at startup). Tokens become invalid on server restart, triggering client re-authentication. **For production:** - Provide an explicit `jwt_signing_key` (e.g., from an environment variable) rather than relying on the auto-derived key. + Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the auto-generated one. </ParamField> <ParamField body="client_storage" type="AsyncKeyValue | None"> @@ -170,9 +162,10 @@ Set this if your provider requires a specific authentication method and the defa Storage backend for persisting OAuth client registrations and upstream tokens. **Default behavior:** - Encrypted disk store in your platform's data directory (derived from `platformdirs`), on every platform including Linux. The encryption key is itself derived from `jwt_signing_key`. + - **Mac/Windows**: Encrypted DiskStore in your platform's data directory (derived from `platformdirs`) + - **Linux**: MemoryStore (ephemeral - clients lost on restart) - By default, clients are automatically persisted to encrypted disk storage, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly. + By default on Mac/Windows, clients are automatically persisted to encrypted disk storage, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly. On Linux where keyring isn't available, ephemeral storage is used to match the ephemeral key strategy. For production deployments with multiple servers or cloud deployments, use a network-accessible storage backend rather than local disk storage. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest.** See [Storage Backends](/servers/storage-backends) for available options. @@ -206,7 +199,7 @@ auth = OIDCProxy( </ParamField> <ParamField body="require_authorization_consent" type='bool | Literal["remember", "external"]' default="True"> - Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs. + Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (consent handled by upstream IdP or custom page), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs. </ParamField> <ParamField body="consent_csp_policy" type="str | None" default="None"> diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx index e2fd14b98..957cad053 100644 --- a/docs/servers/auth/remote-oauth.mdx +++ b/docs/servers/auth/remote-oauth.mdx @@ -116,6 +116,8 @@ auth = RemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")], base_url="https://api.yourcompany.com", # Your server base URL + # Optional: restrict allowed client redirect URIs + allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"] ) mcp = FastMCP(name="Company API", auth=auth) @@ -214,7 +216,13 @@ WorkOS's support for Dynamic Client Registration makes it particularly well-suit ## Client Redirect URI Security <Note> -Redirect URIs are validated by the DCR provider itself, since it owns the registration flow. To constrain them from the FastMCP side, use [`OAuthProxy`](/servers/auth/oauth-proxy), whose `allowed_client_redirect_uris` parameter accepts a list of allowed patterns with wildcard support. +`RemoteAuthProvider` also supports the `allowed_client_redirect_uris` parameter for controlling which redirect URIs are accepted from MCP clients during DCR: + +- `None` (default): Broad DCR-compatible redirect support, while rejecting unsafe browser schemes such as `javascript:`, `data:`, `file:`, and `vbscript:` +- Custom list: Specify allowed patterns with wildcard support +- Empty list `[]`: No redirect URIs allowed + +This provides defense-in-depth even though DCR providers typically validate redirect URIs themselves. </Note> ## Implementation Considerations diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx index 9e55640ba..b21e8b54d 100644 --- a/docs/servers/auth/token-verification.mdx +++ b/docs/servers/auth/token-verification.mdx @@ -80,19 +80,6 @@ This configuration creates a server that validates JWTs issued by `auth.yourcomp The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server. -`JWTVerifier` accepts RSA (`RS*` and `PS*`), ECDSA (`ES*`), and Edwards-curve (`Ed25519` and `Ed448`) signatures from JWKS endpoints. Set `algorithm` when your issuer does not use the default `RS256`: - -```python -verifier = JWTVerifier( - jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", - issuer="https://auth.yourcompany.com", - audience="mcp-production-api", - algorithm="Ed25519", -) -``` - -The legacy `EdDSA` identifier is also accepted for compatibility with identity providers that have not yet adopted the fully specified identifiers from RFC 9864. - ### Symmetric Key Verification (HMAC) Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators. @@ -134,7 +121,7 @@ The parameter is named `public_key` for backwards compatibility, but when using ### Static Public Key Verification -Static public key verification works when you have a fixed RSA, ECDSA, or EdDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available. +Static public key verification works when you have a fixed RSA or ECDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available. ```python from fastmcp import FastMCP @@ -154,7 +141,7 @@ verifier = JWTVerifier( mcp = FastMCP(name="Protected API", auth=verifier) ``` -This configuration validates tokens using a specific RSA, ECDSA, or EdDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys. +This configuration validates tokens using a specific RSA or ECDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys. ## Opaque Token Verification Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662). @@ -438,3 +425,4 @@ mcp = FastMCP(name="Production API", auth=verifier) This keeps configuration out of your codebase while maintaining explicit setup. This approach enables the same codebase to run across development, staging, and production environments with different authentication requirements. Development might use static tokens while production uses JWT verification, all controlled through environment configuration. + diff --git a/docs/servers/authorization.mdx b/docs/servers/authorization.mdx index 1ba9a3b0e..a48d2a9e8 100644 --- a/docs/servers/authorization.mdx +++ b/docs/servers/authorization.mdx @@ -58,75 +58,6 @@ def read_write_operation() -> str: return "Read/write action completed" ``` -### require_roles - -<VersionBadge version="4.0.0" /> - -Scopes are standardized, so `require_scopes` works the same everywhere. Roles and groups are not part of OIDC, so every identity provider puts them under a different claim. `require_roles` handles the comparison and takes an `extract` callable that tells it where to look. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_roles - -def keycloak_roles(claims: dict) -> list[str]: - return claims["realm_access"]["roles"] - -mcp = FastMCP("Role Server") - -@mcp.tool(auth=require_roles("admin", extract=keycloak_roles)) -def admin_operation() -> str: - """Requires the 'admin' role.""" - return "Admin action completed" - -@mcp.tool(auth=require_roles("admin", "auditor", extract=keycloak_roles)) -def audited_admin_operation() -> str: - """Requires both the 'admin' AND 'auditor' roles.""" - return "Audited admin action" -``` - -Multiple roles are required together, matching `require_scopes`. A token whose claims lack the path entirely is denied rather than raising, so the extractor can index directly. - -Keeping the claim path at the call site means any provider works, including ones with unusual shapes. Common locations: - -| Provider | Extractor | -| --- | --- | -| Keycloak | `lambda c: c["realm_access"]["roles"]` | -| Microsoft Entra | `lambda c: c["roles"]` | -| AWS Cognito | `lambda c: c["cognito:groups"]` | -| Auth0 | `lambda c: c["permissions"]` | - -Verify the claim against your own tenant before relying on it. Auth0's namespaced custom claims are configured per tenant, and Entra emits `roles` or `groups` depending on the app manifest. - -<Note> -`require_roles` cannot signal a scope shortfall, because OAuth has no way to request a role. A role denial surfaces as a plain `AuthorizationError` rather than one of the `insufficient_scope` challenges described in [Signaling Scope Shortfalls](#signaling-scope-shortfalls), and it suppresses any scope shortfall raised alongside it — a caller blocked by their role should not be told to go obtain a scope that would not help them. Combining `require_roles` with `require_scopes` is otherwise fine: whenever the role check passes, a scope shortfall is reported as usual. -</Note> - -### Checking Other Claims - -`require_roles` is a convenience for the common case. `AccessToken.claims` holds every claim from the token, so gating on anything else needs no special API — just an auth check that reads it. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import AuthCheck, AuthContext - -mcp = FastMCP("Claim Server") - -def require_tenant(tenant_id: str) -> AuthCheck: - """Require the token to come from a specific tenant.""" - def check(ctx: AuthContext) -> bool: - if ctx.token is None: - return False - return ctx.token.claims.get("tid") == tenant_id - return check - -@mcp.tool(auth=require_tenant("acme")) -def tenant_operation() -> str: - """Only callable by tokens issued for the acme tenant.""" - return "Tenant action completed" -``` - -The same caveat applies: a check like this is opaque, so it suppresses scope disclosure for its siblings. - ### restrict_tag Tag-based restrictions apply scope requirements conditionally. If a component has the specified tag, the token must have the required scopes. Components without the tag are unaffected. @@ -176,7 +107,7 @@ Any callable that accepts `AuthContext` and returns `bool` can serve as an auth ```python from fastmcp import FastMCP -from fastmcp.server.auth import AuthCheck, AuthContext +from fastmcp.server.auth import AuthContext mcp = FastMCP("Custom Auth Server") @@ -186,7 +117,7 @@ def require_premium_user(ctx: AuthContext) -> bool: return False return ctx.token.claims.get("premium", False) is True -def require_access_level(minimum_level: int) -> AuthCheck: +def require_access_level(minimum_level: int): """Factory function for level-based authorization.""" def check(ctx: AuthContext) -> bool: if ctx.token is None: @@ -237,7 +168,6 @@ Sync and async checks can be freely combined in a list — each check is handled Auth checks can raise exceptions for explicit denial with custom messages: - **`AuthorizationError`**: Propagates with its custom message, useful for explaining why access was denied -- **`InsufficientScopeError`**: A subclass of `AuthorizationError` raised by `AuthMiddleware` when the denial is a missing scope; it [names the scopes the caller needs](#signaling-scope-shortfalls) - **Other exceptions**: Masked for security (logged internally, treated as denial) ```python @@ -285,7 +215,7 @@ Component-level `auth` controls both visibility (list filtering) and access (dir ## Server-Level Authorization -For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. When the denial is specifically a missing scope, the error [names the scopes the caller needs](#signaling-scope-shortfalls). +For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. ```python from fastmcp import FastMCP @@ -366,48 +296,6 @@ def read_record(id: str) -> str: return f"Record {id}" ``` -### Signaling Scope Shortfalls - -<VersionBadge version="4.0.0" /> - -A denial is more useful when it says what would fix it. When `AuthMiddleware` blocks a call because the token is missing scopes — rather than because some other policy rejected it — it raises `InsufficientScopeError`, which carries the specific scopes the caller needs in its `required_scopes` attribute. An agent that reads the error knows exactly which scopes to re-authorize for, instead of retrying blindly against an opaque refusal. - -`InsufficientScopeError` subclasses `AuthorizationError`, so existing handlers that catch `AuthorizationError` keep catching it and nothing about your error handling has to change to adopt this. - -Only the scopes the token *lacks* are named, so re-authorizing accumulates permissions rather than replacing them. A caller holding `read` that needs `read` and `write` is told to obtain `write` alone, and keeps `read` through the re-authorization. When several scope requirements fail at once, every unmet scope is reported together — a caller granted them all in one round succeeds on the retry, instead of discovering the next missing scope only after obtaining the first. - -```python -from fastmcp import FastMCP -from fastmcp.exceptions import InsufficientScopeError -from fastmcp.server.auth import require_scopes -from fastmcp.server.middleware import AuthMiddleware - -mcp = FastMCP( - "Step-Up Server", - middleware=[AuthMiddleware(auth=require_scopes("read", "write"))], -) - -@mcp.tool -def update_record(id: str) -> str: - """Requires both 'read' and 'write'.""" - return f"Updated {id}" - -# A token holding only "read" is denied with: -# InsufficientScopeError(required_scopes=["write"]) -``` - -This holds across several `AuthMiddleware` instances too, not just several checks within one. In the [tag-based configuration](#tag-based-global-authorization) each middleware contributes its own requirement, and the first to find a shortfall reports the requirements of the others alongside its own — so one re-authorization covers the whole chain rather than one layer at a time. - -A shortfall is reported only when the scope requirement is what actually caused the denial. If you [combine checks](#combining-checks) and a non-scope check rejects the request first — a tenant policy, say — the denial stays a plain `AuthorizationError` and names no scopes at all. Disclosing a scope requirement for a component the caller could not reach anyway would leak information about components they are not authorized to see. - -That rule also bounds what gets aggregated. Combining requirements only reaches as far down the chain as the request itself would have gone: it stops at the first layer holding a custom check, since whether that layer would admit the caller is unknown until it runs, and running it early would trigger authorization logic the request had not reached yet. Requirements at or beyond that point sit behind an unverified gate and are left out. - -So a custom check early in the chain makes the reported set partial, and a caller may need more than one round to satisfy everything. The reported set is complete when the layers ahead are scope-only and conservative otherwise: it may name fewer scopes than the full chain requires, but it never names scopes behind a policy that might reject the caller regardless. - -<Note> -This names the missing scopes in the error rather than emitting an HTTP `403` challenge. A per-tool denial is a JSON-RPC error carried inside a `200` response, so there is no HTTP status at that layer to attach a `WWW-Authenticate` header to. Token-level scope failures — where the token does not satisfy the server's own `required_scopes` — are a separate concern handled by the transport middleware, which does return a spec-correct `403` with an `insufficient_scope` challenge. -</Note> - ## Accessing Tokens in Tools Tools can access the current authentication token using `get_access_token()` from `fastmcp.server.dependencies`. This enables tools to make decisions based on user identity or permissions beyond simple authorization checks. @@ -488,15 +376,9 @@ from fastmcp.server.auth import ( AuthContext, # Context with .token, .component AuthCheck, # Type alias: sync or async Callable[[AuthContext], bool] require_scopes, # Built-in: requires specific scopes - require_roles, # Built-in: requires roles read from token claims restrict_tag, # Built-in: tag-based scope requirements run_auth_checks, # Utility: run checks with AND logic ) -from fastmcp.exceptions import ( - AuthorizationError, # Denial with a custom message - InsufficientScopeError, # Subclass of AuthorizationError; has .required_scopes -) - from fastmcp.server.middleware import AuthMiddleware ``` diff --git a/docs/servers/completions.mdx b/docs/servers/completions.mdx deleted file mode 100644 index d59a18488..000000000 --- a/docs/servers/completions.mdx +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: Argument Completion -sidebarTitle: Completions -description: Suggest values for prompt arguments and resource template parameters as the user types. -icon: list-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="4.0.0" /> - -Argument completion lets a server suggest values while a user fills in a prompt argument or a resource template parameter. As the user types, the client sends a `completion/complete` request naming the prompt or template, the argument being completed, and the partial value so far. The server answers with candidate strings, which the client offers as autocomplete suggestions. - -This is the server side of the feature. A client requests completions with [`Client.complete()`](/clients/client); this page covers how a server answers. - -## Register a completion handler - -A server has a single completion handler, registered with the `@mcp.completion` decorator. The handler receives every completion request and switches on which reference and argument is being completed. - -```python -from fastmcp import FastMCP -from mcp.types import PromptReference - -mcp = FastMCP("Completion Server") - - -@mcp.prompt -def write_poem(theme: str) -> str: - return f"Write a poem about {theme}" - - -@mcp.completion -def complete(ref, argument, context): - if isinstance(ref, PromptReference) and ref.name == "write_poem": - if argument.name == "theme": - options = ["nature", "love", "adventure"] - return [o for o in options if o.startswith(argument.value)] - return None -``` - -The handler is called with three values: - -- `ref`: which component is being completed — a `PromptReference` (carrying the prompt `name`) or a `ResourceTemplateReference` (carrying the template `uri`). -- `argument`: a `CompletionArgument` with the argument `name` and the partial `value` typed so far. -- `context`: an optional `CompletionContext` carrying the values of arguments the user has already supplied (see [Using already-supplied arguments](#using-already-supplied-arguments)). - -Filter your candidates against `argument.value` so the suggestions narrow as the user types. Returning `None` means "I have no suggestions for this reference and argument" — the client receives an empty list, which is the correct answer for a reference the server does not recognize. - -<Tip> -Registering a completion handler declares the server's completions capability during the handshake. A server with no handler does not advertise the capability, and a client that checks capabilities before calling will skip completion requests entirely. This works the same way on both the handshake and modern protocol eras. -</Tip> - -## Completing resource template parameters - -The same handler answers completion for resource template parameters. A `ResourceTemplateReference` identifies the template by its URI template, and `argument.name` is the parameter being completed. - -```python -from fastmcp import FastMCP -from mcp.types import ResourceTemplateReference - -mcp = FastMCP("Completion Server") - -REPOS = ["fastmcp", "prefect", "marvin"] - - -@mcp.resource("github://{owner}/{repo}") -def repo_readme(owner: str, repo: str) -> str: - return f"README for {owner}/{repo}" - - -@mcp.completion -def complete(ref, argument, context): - if isinstance(ref, ResourceTemplateReference): - if ref.uri == "github://{owner}/{repo}" and argument.name == "repo": - return [r for r in REPOS if r.startswith(argument.value)] - return None -``` - -Because a single handler answers for every prompt and template, a server that completes several components branches on `ref` first, then on `argument.name`. Grouping the branches by reference keeps the handler readable as it grows. - -## Using already-supplied arguments - -Completions often depend on values the user has already entered. A repository suggestion, for example, depends on which owner was chosen. The client sends those resolved values in the completion context, and the handler reads them from `context.arguments`. - -```python -from fastmcp import FastMCP -from mcp.types import ResourceTemplateReference - -mcp = FastMCP("Completion Server") - -REPOS_BY_OWNER = { - "prefecthq": ["fastmcp", "prefect", "marvin"], - "python": ["cpython", "mypy"], -} - - -@mcp.resource("github://{owner}/{repo}") -def repo_readme(owner: str, repo: str) -> str: - return f"README for {owner}/{repo}" - - -@mcp.completion -def complete(ref, argument, context): - if isinstance(ref, ResourceTemplateReference) and argument.name == "repo": - owner = context.arguments.get("owner") if context and context.arguments else None - repos = REPOS_BY_OWNER.get(owner or "", []) - return [r for r in repos if r.startswith(argument.value)] - return None -``` - -Here the suggestions for `repo` are scoped to the `owner` the user already selected. The context is only present once at least one argument has been resolved, so guard against `context` being `None`. - -## Returning results - -A handler may return any of three things: - -- A list of strings — the simplest form, wrapped into a completion response automatically. -- `None` — treated as an empty completion, for references and arguments the handler does not recognize. -- A `Completion` object — when you want to include pagination hints alongside the values. - -The MCP protocol caps a single response at 100 values. When more candidates exist, return a `Completion` and set `total` (how many candidates match in all) and `has_more` (whether values were truncated) so the client can indicate that the list is partial. - -```python -from fastmcp import FastMCP -from mcp.types import Completion, PromptReference - -mcp = FastMCP("Completion Server") - -ALL_CITIES = ["Paris", "Prague", "Portland", "Phoenix", "Perth"] - - -@mcp.prompt -def pick_city(city: str) -> str: - return f"Tell me about {city}" - - -def search_cities(prefix: str) -> list[str]: - # A real lookup might return thousands of matches; ALL_CITIES stands in. - return [c for c in ALL_CITIES if c.startswith(prefix)] - - -@mcp.completion -def complete(ref, argument, context): - if isinstance(ref, PromptReference) and argument.name == "city": - matches = search_cities(argument.value) - return Completion( - values=matches[:100], - total=len(matches), - has_more=len(matches) > 100, - ) - return None -``` - -## Accessing the request context - -A completion handler may be sync or async, and it can reach the active request through FastMCP's dependency functions the same way any handler does. Use [`get_context()`](/servers/context) to access session information, authentication, or server state while computing suggestions. - -```python -from fastmcp import FastMCP -from fastmcp.server.dependencies import get_context -from mcp.types import PromptReference - -mcp = FastMCP("Completion Server") - - -@mcp.completion -async def complete(ref, argument, context): - ctx = get_context() - await ctx.debug(f"Completing {argument.name!r} for {ref}") - ... -``` - -## Authorization - -Completion runs behind the server's connection-level authentication: an unauthenticated client never reaches the handler. It is independent of per-component `auth=`, though. FastMCP does not resolve the referenced prompt or resource template, so a completion request is not filtered by that component's visibility the way `prompts/get` or a resource read is — the single handler answers for whatever reference the client names. - -A completion response carries only candidate strings for one argument, never component content or schema, so this exposes nothing about a hidden component on its own. If a handler computes candidates that should themselves be restricted — matching a prompt hidden from unauthorized callers, say — check the auth context inside the handler (via [`get_context()`](/servers/context)) and return `None` when the caller is not permitted. diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 9175c2438..42523a5cd 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -199,28 +199,23 @@ If low latency is critical, consider implementing caching strategies or limiting Custom HTTP routes defined with `@server.custom_route()` are also forwarded when mounting: ```python -from fastmcp import FastMCP -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - subserver = FastMCP("Sub") @subserver.custom_route("/health", methods=["GET"]) -async def health_check(request: Request) -> Response: - return JSONResponse({"status": "ok"}) +async def health_check(): + return {"status": "ok"} main = FastMCP("Main") main.mount(subserver, namespace="sub") -# /health is now accessible through main's HTTP app. -# Custom route paths are not namespaced by mount(namespace=...). +# /health is now accessible through main's HTTP app ``` ## Conflict Resolution <VersionBadge version="3.0.0" /> -When mounting multiple servers with the same namespace (or no namespace), FastMCP queries all mounted providers for a requested component and returns the highest matching version. If two unversioned components (or two equal versions) use the same identifier, the provider registered first wins. +When mounting multiple servers with the same namespace (or no namespace), the **most recently mounted** server takes precedence for conflicting component names: ```python server_a = FastMCP("A") @@ -238,5 +233,5 @@ main = FastMCP("Main") main.mount(server_a) main.mount(server_b) -# shared_tool returns "From A" (first mounted, same unversioned key) +# shared_tool returns "From B" (most recently mounted) ``` diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index 667ce9764..ecd9e67ea 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -21,8 +21,9 @@ The `Context` object provides a clean interface to access MCP features within yo - **Progress Reporting**: Update the client on the progress of long-running operations - **Resource Access**: List and read data from resources registered with the server - **Prompt Access**: List and retrieve prompts registered with the server +- **LLM Sampling**: Request the client's LLM to generate text based on provided messages - **User Elicitation**: Request structured input from users during tool execution -- **Request State**: Pass values and non-serializable resources between middleware and handlers within a request (for state that persists across requests, see [Session State](/servers/sessions)) +- **Session State**: Store data that persists across requests within an MCP session - **Session Visibility**: [Control which components are visible](/servers/visibility#per-session-visibility) to the current session - **Request Information**: Access metadata about the current request - **Server Access**: When needed, access the underlying FastMCP server instance @@ -70,7 +71,7 @@ async def data_analysis_request(dataset: str, ctx: Context = CurrentContext()) - - Dependency parameters are automatically excluded from the MCP schema—clients never see them. - Context methods are async, so your function usually needs to be async as well. -- **Each MCP request receives a new context object.** State set with `ctx.set_state()` is scoped to that request and is not available in subsequent ones. To persist state across requests, use [Session State](/servers/sessions). +- **Each MCP request receives a new context object.** Context is scoped to a single request; state or data set in one request will not be available in subsequent requests. - Context is only available during a request; attempting to use context methods outside a request will raise errors. ### Legacy Type-Hint Injection @@ -151,9 +152,18 @@ if result.action == "accept": See [User Elicitation](/servers/elicitation) for detailed examples and supported response types. -### Sampling and Roots +### LLM Sampling + +<VersionBadge version="2.0.0" /> + +Request the client's LLM to generate text based on provided messages, useful for leveraging AI capabilities within your tools. + +```python +response = await ctx.sample("Analyze this data", temperature=0.7) +``` + +See [LLM Sampling](/servers/sampling) for comprehensive usage and advanced techniques. -Neither capability has a `Context` method. Both used to *push* a request into a live client connection, which the modern MCP protocol has no channel to carry, so a tool now asks for them by returning the request and reading the answer on the next round — the same [guard pattern](/servers/elicitation#sampling-and-roots) elicitation uses on modern connections. That route is the natural one for roots; for generation, [call an LLM directly from your server](/servers/sampling). ### Progress Reporting @@ -174,13 +184,13 @@ List and read data from resources registered with your FastMCP server, allowing resources = await ctx.list_resources() # Read a specific resource -resource_result = await ctx.read_resource("resource://config") -content = resource_result.contents[0].content +content_list = await ctx.read_resource("resource://config") +content = content_list[0].content ``` **Method signatures:** -- **`ctx.list_resources() -> list[mcp.types.Resource]`**: <VersionBadge version="2.13.0" /> Returns list of all available resources -- **`ctx.read_resource(uri: str | AnyUrl) -> ResourceResult`**: Returns a `ResourceResult` whose `.contents` list contains the resource content parts +- **`ctx.list_resources() -> list[MCPResource]`**: <VersionBadge version="2.13.0" /> Returns list of all available resources +- **`ctx.read_resource(uri: str | AnyUrl) -> list[ReadResourceContents]`**: Returns a list of resource content parts ### Prompt Access @@ -201,68 +211,118 @@ messages = result.messages - **`ctx.list_prompts() -> list[MCPPrompt]`**: Returns list of all available prompts - **`ctx.get_prompt(name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult`**: Get a specific prompt with optional arguments -### Request State +### Session State <VersionBadge version="3.0.0" /> -Request state carries values *within a single request*, across the middleware → handler pipeline. A request runs through any middleware you've added and then the handler — separate functions that don't share a stack frame, so a plain local variable can't pass anything between them. `ctx.set_state` / `ctx.get_state` is that channel. +Store data that persists across multiple requests within the same MCP session. Session state is automatically keyed by the client's session, ensuring isolation between different clients. -The common case is a middleware that resolves something once and every tool reads it, rather than each tool recomputing it: +```python +from fastmcp import FastMCP, Context + +mcp = FastMCP("stateful-app") + +@mcp.tool +async def increment_counter(ctx: Context) -> int: + """Increment a counter that persists across tool calls.""" + count = await ctx.get_state("counter") or 0 + await ctx.set_state("counter", count + 1) + return count + 1 + +@mcp.tool +async def get_counter(ctx: Context) -> int: + """Get the current counter value.""" + return await ctx.get_state("counter") or 0 +``` + +Each client session has its own isolated state—two different clients calling `increment_counter` will each have their own counter. + +**Method signatures:** +- **`await ctx.set_state(key, value, *, serializable=True)`**: Store a value in session state +- **`await ctx.get_state(key)`**: Retrieve a value (returns None if not found) +- **`await ctx.delete_state(key)`**: Remove a value from session state + +<Note> +State methods are async and require `await`. State expires after 1 day to prevent unbounded memory growth. +</Note> + +#### Non-Serializable Values + +By default, state values must be JSON-serializable (dicts, lists, strings, numbers, etc.) so they can be persisted across requests. For non-serializable values like HTTP clients or database connections, pass `serializable=False`: + +```python +@mcp.tool +async def my_tool(ctx: Context) -> str: + # This object can't be JSON-serialized + client = SomeHTTPClient(base_url="https://api.example.com") + await ctx.set_state("client", client, serializable=False) + + # Retrieve it later in the same request + client = await ctx.get_state("client") + return await client.fetch("/data") +``` + +Values stored with `serializable=False` only live for the current MCP request (a single tool call, resource read, or prompt render). They will not be available in subsequent requests within the session. + +#### Custom Storage Backends + +By default, session state uses an in-memory store suitable for single-server deployments. For distributed or serverless deployments, provide a custom storage backend: + +```python +from key_value.aio.stores.redis import RedisStore + +# Use Redis for distributed state +mcp = FastMCP("distributed-app", session_state_store=RedisStore(...)) +``` + +Any backend compatible with the [py-key-value-aio](https://github.com/strawgate/py-key-value) `AsyncKeyValue` protocol works. See [Storage Backends](/servers/storage-backends) for more options including Redis, DynamoDB, and MongoDB. + +#### State and Mounted Servers + +Each `FastMCP` instance has its own session state store. When you `mount()` a child server, state set on the parent is not visible to tools on the child, and vice versa: ```python from fastmcp import FastMCP, Context from fastmcp.server.middleware import Middleware, MiddlewareContext -mcp = FastMCP("app") +parent = FastMCP("Parent") +child = FastMCP("Child") +parent.mount(child, namespace="child") - -class Enrich(Middleware): +class Stasher(Middleware): async def on_call_tool(self, context: MiddlewareContext, call_next): - await context.fastmcp_context.set_state("caller", "alice") + await context.fastmcp_context.set_state("user", "alice") return await call_next(context) +parent.add_middleware(Stasher()) -mcp.add_middleware(Enrich()) - - -@mcp.tool +@child.tool async def whoami(ctx: Context) -> str: - return await ctx.get_state("caller") or "unknown" + return await ctx.get_state("user") or "unknown" # returns "unknown" ``` -The state is scoped to the one request and discarded when it returns. State is also inherited by mounted children, so a value a parent middleware sets is visible to a mounted server's tools within the same request. - -**Method signatures:** - -- **`await ctx.set_state(key, value, *, serializable=True)`** — store a value -- **`await ctx.get_state(key)`** — retrieve a value (returns `None` if not set) -- **`await ctx.delete_state(key)`** — remove a value - -#### Non-serializable resources - -The most useful thing request state holds is objects you *can't* persist — a database connection or an HTTP client that a middleware or the [lifespan](/servers/lifespan) opens and a handler uses. Pass `serializable=False`: +To share state across the mount boundary, pass the same store to both servers: ```python -@mcp.tool -async def my_tool(ctx: Context) -> str: - client = SomeHTTPClient(base_url="https://api.example.com") - await ctx.set_state("client", client, serializable=False) +from key_value.aio.stores.memory import MemoryStore - client = await ctx.get_state("client") - return await client.fetch("/data") +store = MemoryStore() +parent = FastMCP("Parent", session_state_store=store) +child = FastMCP("Child", session_state_store=store) +parent.mount(child, namespace="child") ``` -A `serializable=False` value lives on the request context for the current call only. It is inherently request-scoped — a live connection can't be serialized and stored — which is exactly why it belongs here rather than in a persistent store. +Alternatively, state set with `serializable=False` lives on the request context and is inherited by mounted children automatically — use it when the value is request-scoped and does not need to persist across tool calls. -#### Persisting across requests +#### State During Initialization -Request state does not survive from one call to the next. When you need a cart, a conversation, or any state that outlives a single request, use [Session State](/servers/sessions) — it stores server-side, keyed by the authenticated user, and works on every protocol era. (On session-based, handshake-era connections, serializable request state also persists across the session, but Session State is the deliberate, cross-era way to do it.) +State set during `on_initialize` middleware persists to subsequent tool calls when using the same session object (STDIO, SSE, single-server HTTP). For distributed/serverless HTTP deployments where different machines handle init and tool calls, state is isolated by the `mcp-session-id` header. ### Session Visibility <VersionBadge version="3.0.0" /> -Tools can customize which components are visible to their current session using `ctx.enable_components()`, `ctx.disable_components()`, and `ctx.reset_visibility()`. They accept the same filters as the server-level methods, so `names={"search"}` targets a component by name and `tags` targets a group. These methods apply visibility rules that affect only the calling session, leaving other sessions unchanged. See [Per-Session Visibility](/servers/visibility#per-session-visibility) for complete documentation, filter criteria, and patterns like namespace activation. +Tools can customize which components are visible to their current session using `ctx.enable_components()`, `ctx.disable_components()`, and `ctx.reset_visibility()`. These methods apply visibility rules that affect only the calling session, leaving other sessions unchanged. See [Per-Session Visibility](/servers/visibility#per-session-visibility) for complete documentation, filter criteria, and patterns like namespace activation. ### Change Notifications @@ -271,18 +331,14 @@ Tools can customize which components are visible to their current session using FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context's notification methods: ```python -from mcp.types import ( - PromptListChangedNotification, - ResourceListChangedNotification, - ToolListChangedNotification, -) +import mcp_types @mcp.tool async def custom_tool_management(ctx: Context) -> str: """Example of manual notification after custom tool changes.""" - await ctx.send_notification(ToolListChangedNotification()) - await ctx.send_notification(ResourceListChangedNotification()) - await ctx.send_notification(PromptListChangedNotification()) + await ctx.send_notification(mcp_types.ToolListChangedNotification()) + await ctx.send_notification(mcp_types.ResourceListChangedNotification()) + await ctx.send_notification(mcp_types.PromptListChangedNotification()) return "Notifications sent" ``` diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index c9ada083b..40fc7b65b 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx"; FastMCP uses dependency injection to provide runtime values to your tools, resources, and prompts. Instead of passing context through every layer of your code, you declare what you need as parameter defaults—FastMCP resolves them automatically when your function runs. -The dependency injection system is powered by [uncalled-for](https://github.com/chrisguidry/uncalled-for), the same dependency engine used by Docket. Core DI features like `Depends()` and `CurrentContext()` work without installing Docket. Background task execution and task-specific dependencies such as `CurrentDocket()` and `CurrentWorker()` require `fastmcp[tasks]`. For comprehensive coverage of dependency patterns, see the [Docket dependency documentation](https://docket.lol/en/latest/dependency-injection/). +The dependency injection system is powered by [Docket](https://github.com/chrisguidry/docket) and its dependency system [uncalled-for](https://github.com/chrisguidry/uncalled-for). Core DI features like `Depends()` and `CurrentContext()` work without installing Docket. For background tasks and advanced task-related dependencies, install `fastmcp[tasks]`. For comprehensive coverage of dependency patterns, see the [Docket dependency documentation](https://docket.lol/en/latest/dependency-injection/). <Note> Dependency parameters are automatically excluded from the MCP schema—clients never see them as callable parameters. This separation keeps your function signatures clean while giving you access to the runtime context you need. @@ -160,9 +160,10 @@ def get_client_ip() -> str: ``` <Note> -Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport, -or inside a background task — there is no live request object to reconstruct there). -Use HTTP Headers below if you need graceful fallback, including inside background tasks. +Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport). +For background tasks created from an HTTP request, FastMCP restores a minimal request +backed by the originating request's snapshotted headers. Use HTTP Headers if you need +graceful fallback. </Note> ### HTTP Headers @@ -277,12 +278,11 @@ Common claims vary by identity provider: <VersionBadge version="2.3.0" /> -For background task execution, FastMCP provides dependencies that integrate with [Docket](https://github.com/chrisguidry/docket). `CurrentDocket()` and `CurrentWorker()` require installing `fastmcp[tasks]`; `Progress()` also works during immediate foreground execution with an in-memory tracker, and delegates to Docket progress when a Docket worker context is active. +For background task execution, FastMCP provides dependencies that integrate with [Docket](https://github.com/chrisguidry/docket). These require installing `fastmcp[tasks]`. ```python from fastmcp import FastMCP -from fastmcp.dependencies import Progress -from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker +from fastmcp.dependencies import CurrentDocket, CurrentWorker, Progress mcp = FastMCP("Task Demo") @@ -309,7 +309,7 @@ async def long_running_task( - **`Progress()`**: Track task progress with atomic updates <Note> -`CurrentDocket()` and `CurrentWorker()` require `pip install 'fastmcp[tasks]'`. They resolve once the server lifespan has initialized Docket, which happens as soon as any component on the server is task-enabled — so regular foreground tools, resources, and prompts can inject them too, not only task-enabled components. `Progress()` can be injected anywhere regardless, though cross-process task progress requires Docket. For comprehensive task patterns, see the [Docket documentation](https://chrisguidry.github.io/docket/dependencies/). +Task dependencies require `pip install 'fastmcp[tasks]'`. They're only available within task-enabled components (`task=True`). For comprehensive task patterns, see the [Docket documentation](https://chrisguidry.github.io/docket/dependencies/). </Note> ## Custom Dependencies diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx index eacde0b60..923e704c6 100644 --- a/docs/servers/elicitation.mdx +++ b/docs/servers/elicitation.mdx @@ -1,7 +1,7 @@ --- title: User Elicitation sidebarTitle: Elicitation -description: Ask users for input while a tool is running, on both the handshake and modern protocols. +description: Request structured input from users during tool execution through the MCP context. icon: message-question --- @@ -9,9 +9,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx' <VersionBadge version="2.10.0" /> -User elicitation allows MCP servers to request input from users during tool execution. Instead of requiring all inputs upfront, tools can interactively ask for missing parameters, clarification, or additional context as needed. +User elicitation allows MCP servers to request structured input from users during tool execution. Instead of requiring all inputs upfront, tools can interactively ask for missing parameters, clarification, or additional context as needed. -Elicitation enables tools to request specific information from users mid-task: +Elicitation enables tools to pause execution and request specific information from users: - **Missing parameters**: Ask for required information not provided initially - **Clarification requests**: Get user confirmation or choices for ambiguous scenarios @@ -20,18 +20,9 @@ Elicitation enables tools to request specific information from users mid-task: For example, a file management tool might ask "Which directory should I create?" or a data analysis tool might request "What date range should I analyze?" -## Which approach to use +## Overview -Elicitation reaches the user two different ways, depending on the protocol era the connection negotiated: - -- **On handshake-era connections (≤ 2025-11-25)**, a running tool calls [`ctx.elicit()`](#requesting-input-on-handshake-connections). The tool pauses mid-execution, the server sends a request over the session back-channel, and the tool resumes with the answer. This is the original elicitation API and the rest of this page's first half covers it in full. -- **On the modern protocol (2026-07-28)**, that back-channel is gone — server-initiated requests were removed from the wire (SEP-2577), so a tool cannot issue a request mid-execution and block on the answer. Instead a tool asks for input by *returning* a description of what it needs; each round completes normally and the client issues a new call with the answer attached. This is the [guard pattern](#elicitation-on-the-modern-protocol), covered in the second half. - -The era gate is strict: `ctx.elicit()` only works on handshake connections, and the guard pattern only works on modern ones. A tool that returns a guard result on a handshake connection — or calls `ctx.elicit()` on a modern one — raises a clear era error rather than failing obscurely. A server that serves both eras may need both paths; branch on `ctx.request_context.protocol_version` to pick the right one. `fastmcp.Client` drives whichever the connection negotiated automatically. - -## Requesting input on handshake connections - -Use the `ctx.elicit()` method within any tool function to request user input on a handshake-era connection. Specify the message to display and the type of response you expect. +Use the `ctx.elicit()` method within any tool function to request user input. Specify the message to display and the type of response you expect. ```python from fastmcp import FastMCP, Context @@ -187,16 +178,16 @@ async def confirm_purchase(ctx: Context) -> str: These arguments only apply when FastMCP is adding the wrapper. For structured responses (`BaseModel`, dataclass, `TypedDict`), set the metadata on the individual fields via `Field(title=..., description=...)` — passing `response_title` or `response_description` alongside a model type raises `TypeError`. -### Confirmations +### No Response -`response_type` is required. When all you want is a yes/no answer, ask for a `bool` rather than an empty schema — an empty schema gives the client nothing to render, and some clients show an empty, non-functional form. +Sometimes, the goal of an elicitation is to simply get a user to approve or reject an action. Pass `None` as the response type to indicate that no data is expected. The `data` field will be `None` when the user accepts. ```python @mcp.tool async def approve_action(ctx: Context) -> str: - result = await ctx.elicit("Approve this action?", response_type=bool) + result = await ctx.elicit("Approve this action?", response_type=None) - if result.action == "accept" and result.data: + if result.action == "accept": return do_action() else: raise ValueError("Action rejected") @@ -386,248 +377,3 @@ async def create_task(ctx: Context) -> str: ``` Default values are supported for strings, integers, numbers, booleans, and enums. - -## Elicitation on the modern protocol - -<VersionBadge version="4.0.0" /> - -The modern protocol (2026-07-28) removes the server-initiated back-channel that `ctx.elicit()` depends on (SEP-2577), so a running tool has no way to reach the user mid-execution. Elicitation reaches the user a different way: a tool asks for input by *returning* a description of what it needs. That return value completes the call normally — the result just happens to be an `InputRequiredResult` describing a request rather than a final answer. The client fulfils the request and issues a **new** tool call with the answer attached, and the tool runs again from the top, sees the answer, and either asks for the next thing or returns its final result. - -Every round is a complete, independent request→response cycle: the tool holds no state between rounds, and nothing on the server stays alive waiting between them. That makes elicitation work on stateless, serverless, and load-balanced deployments where no two rounds are guaranteed to land on the same worker. A booking tool can ask for a destination, then a date, then confirm, across as many rounds as the work requires, without keeping a connection or a server-side session alive in between. - -<Note> -This pattern requires an MCP **2026-07-28** connection. The `InputRequiredResult` result type does not exist on earlier protocol versions; a tool that returns one on a handshake-era connection raises a clear error (see [Protocol requirements](#protocol-requirements)). On those connections, use [`ctx.elicit()`](#requesting-input-on-handshake-connections) instead. -</Note> - -### How it works - -A tool that asks for input this way is a **guard**: each round it re-runs from the top, checks whether the answers it needs are present, and either asks for more or proceeds. Each of those rounds is an ordinary tool call that runs the full request path — middleware chain included — and returns a result like any other; the framework does not hold the call open between rounds. It inspects two request-scoped properties on the `Context` to decide what to do: - -- `ctx.input_responses` — the client's answers to what you asked on a previous round. It is `None` on the very first round (nothing has been asked yet) and a mapping of answers on later rounds. -- `ctx.request_state` — a small opaque string you can carry from one round to the next. It is `None` on the first round and echoes back whatever you last put in `InputRequiredResult.request_state`. - -To ask for input, return an `InputRequiredResult` whose `input_requests` map describes the requests to run — most commonly an elicitation. Each request has a key; the client's answer comes back under the same key in `ctx.input_responses`. - -The following tool books a flight across three rounds: it asks for a destination, then asks for a date (carrying the destination forward), then confirms the booking. - -```python -from fastmcp import FastMCP, Context -from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams - -mcp = FastMCP("Booking Server") - - -def ask(key: str, message: str, field: str, request_state: str | None = None) -> InputRequiredResult: - """Build an InputRequiredResult that elicits a single text field.""" - params = ElicitRequestFormParams( - message=message, - requested_schema={ - "type": "object", - "properties": {field: {"type": "string"}}, - "required": [field], - }, - ) - elicitation = ElicitRequest(method="elicitation/create", params=params) - return InputRequiredResult( - result_type="input_required", - input_requests={key: elicitation}, - request_state=request_state, - ) - - -@mcp.tool -async def book_flight(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - - if responses is None: - return ask("destination", "Where would you like to fly?", "destination") - - if "destination" in responses: - destination = responses["destination"].content["destination"] - return ask( - "date", - f"When would you like to fly to {destination}?", - "date", - request_state=f"dest={destination}", - ) - - destination = ctx.request_state.split("=", 1)[1] - date = responses["date"].content["date"] - return f"Booked a flight to {destination} on {date}" -``` - -The tool runs three times for one logical call. On the first run `ctx.input_responses` is `None`, so it asks for a destination. On the second run the destination is present, so it asks for a date and stashes the destination in `request_state`. On the third run the date is present, so it reads the destination back out of `ctx.request_state` and returns the booking. Each round is a fresh execution — the tool holds no state of its own between rounds; everything it needs travels on the request. - -### Reading answers - -Each value in `ctx.input_responses` is the client's result for one request, keyed by the key you gave it. For an elicitation, that is an `ElicitResult` with an `action` and (when accepted) `content`: - -```python -@mcp.tool -async def confirm(ctx: Context) -> str: - responses = ctx.input_responses - if responses is None: - return ask("ok", "Proceed?", "ok") - - answer = responses["ok"] - if answer.action != "accept": - return "Cancelled." - return f"Proceeding with {answer.content['ok']}" -``` - -Always check `answer.action` before reading `answer.content`: a client may **decline** or **cancel**, in which case `content` is absent. A decline is a normal answer, not an error — it is delivered to your tool like any other round so you can handle it deliberately. - -### Driving the loop from a client - -`fastmcp.Client` drives the whole loop automatically. Point it at a 2026-era connection (`mode="auto"` negotiates one) and give it an elicitation handler; it fulfils each round's requests and retries until the tool returns its final result. - -```python -from fastmcp import Client -from fastmcp.client.elicitation import ElicitResult - - -async def handler(message, response_type, params, ctx): - if "Where" in message: - return ElicitResult(action="accept", content=response_type(destination="Paris")) - return ElicitResult(action="accept", content=response_type(date="2026-08-01")) - - -async with Client(mcp, mode="auto", elicitation_handler=handler) as client: - result = await client.call_tool("book_flight", {}) - print(result.data) # "Booked a flight to Paris on 2026-08-01" -``` - -The client caps the number of rounds it will drive (`input_required_max_rounds`, default 10) so a misbehaving guard cannot loop forever; exceeding it raises an error rather than hanging. - -### Carrying state across rounds - -The `request_state` you return is **sealed by the framework** before it reaches the wire and **unsealed and verified** before your tool runs again. Your tool only ever mints and reads plaintext — the client receives an opaque token it cannot read, and a token that has been tampered with, has expired, or was minted by a different server is rejected before your tool sees it. You never call any crypto yourself. - -Because sealing is automatic, `request_state` is a safe place to carry a computed value forward instead of re-deriving it each round. Keep it small — it round-trips through the client on every leg. - -#### Multi-replica deployments - -By default each server process seals under a per-process **ephemeral key**. That is correct for single-process deployments (stdio, one HTTP worker), but it means state minted by one process is rejected by another — so a horizontally scaled deployment, where consecutive rounds may land on different replicas, needs a **shared key**. - -Give every replica the same key (or key ring) via `request_state_security`: - -```python -import os -from fastmcp import FastMCP -from mcp.server.request_state import RequestStateSecurity - -mcp = FastMCP( - "Booking Server", - request_state_security=RequestStateSecurity(keys=[os.environ["REQUEST_STATE_KEY"].encode()]), -) -``` - -Keys must be at least 32 bytes of secret randomness. `keys` is a rotation ring: `keys[0]` seals, and every key in the ring can unseal, so you can rotate without downtime by rolling `keys=[old, new]` → `keys=[new, old]` → `keys=[new]` across deployments. Generate a key with: - -```bash -python -c "import secrets; print(secrets.token_hex(32))" -``` - -### Protocol requirements - -The `InputRequiredResult` result type is part of MCP **2026-07-28** and does not exist on earlier protocol versions. If a tool returns one on a handshake-era (≤ 2025-11-25) connection, FastMCP rejects the call with a clear error naming the era mismatch rather than letting it fail as a generic invalid result: - -``` -Tool 'book_flight' returned an InputRequiredResult to request client input, but -the multi-round-trip result type (SEP-2322) only exists at MCP 2026-07-28; this -connection negotiated '2025-11-25'. Use ctx.elicit() for server-initiated input -on handshake-era connections. -``` - -If you need to support both eras, branch on `ctx.request_context.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones. - -### Prompts and resources - -`InputRequiredResult` is a **result type**, not a tools feature: any request can resolve to one. Prompts, resources, and resource templates ask for input exactly the way tools do — return an `InputRequiredResult`, read `ctx.input_responses` on the next round, and the client re-issues the same `prompts/get` or `resources/read` with the answer attached. - -This prompt gathers the context it needs before rendering: - -```python -from fastmcp import FastMCP, Context -from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams - -mcp = FastMCP("Reporting Server") - -ask_for_quarter = InputRequiredResult( - result_type="input_required", - input_requests={ - "quarter": ElicitRequest( - method="elicitation/create", - params=ElicitRequestFormParams( - message="Which quarter should the summary cover?", - requested_schema={ - "type": "object", - "properties": {"quarter": {"type": "string"}}, - "required": ["quarter"], - }, - ), - ) - }, -) - - -@mcp.prompt -async def summarize(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return ask_for_quarter - quarter = responses["quarter"].content["quarter"] - return f"Summarize the {quarter} results." -``` - -Resources and resource templates work the same way, with the URI standing in for the tool name: - -```python -@mcp.resource("report://summary") -async def report(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return ask_for_quarter - quarter = responses["quarter"].content["quarter"] - return f"Revenue report for {quarter}" -``` - -The same protocol requirement applies: returning an `InputRequiredResult` from a prompt or resource needs a 2026-07-28 connection, and FastMCP names the era mismatch if one arrives on an older one. Client-side, `read_resource` and `get_prompt` drive the loop the way `call_tool` does, so a configured elicitation handler answers all three without extra wiring. - -### Sampling and roots - -Elicitation is the most common request to carry this way, and the map carries the others just as well. A `ListRootsRequest` or a `CreateMessageRequest` sits in `input_requests` exactly as an `ElicitRequest` does, and its answer arrives in `ctx.input_responses` under the same key as a `ListRootsResult` or a `CreateMessageResult`. One map can mix all three, and `fastmcp.Client` answers each from the handlers it already has — `elicitation_handler=`, `roots=`, and `sampling_handler=` — so a tool that asks for a mixture needs no extra client wiring. [Client Roots](/clients/roots) covers what a roots request contains. - -Roots and sampling differ in how well they suit the round trip. A server asks for roots once and then has what it needs, so the extra round buys the whole answer. Generation rarely works out that way, because every round is a full request-response cycle and a tool that generates in a loop pays that cost each time — [call an LLM directly from your server](/servers/sampling) unless the point is specifically to use the caller's model. - -### Middleware - -Because each round is a complete request→response cycle, a multi-round tool call runs the **full middleware chain on every round**. `on_call_tool` fires once per round and `call_next(context)` returns that round's result like any other call — there is no held-open call and no special control flow to account for. Default middleware behaves sensibly with no changes: logging logs each round, timing times each round, and error-handling middleware does not fire on an asking round — an ask is a legitimate result, not an error. - -An asking round returns an `InputRequiredToolResult` (a `ToolResult` subclass); the final round returns an ordinary `ToolResult`. Middleware that needs to treat the two differently identifies an ask with an `isinstance` check, and tells an initial round from a continuation round by inspecting `ctx.input_responses` (`None` on the first round, present once the client has answered): - -```python -from fastmcp.server.middleware import Middleware -from fastmcp.tools import InputRequiredToolResult - - -class GuardAwareMiddleware(Middleware): - async def on_call_tool(self, context, call_next): - ctx = context.fastmcp_context - # Either signal marks a continuation: a state-only round carries - # request_state with no answers, so it retries with input_responses=None. - is_continuation = ( - ctx.input_responses is not None or ctx.request_state is not None - ) - - result = await call_next(context) - - if isinstance(result, InputRequiredToolResult): - ... # this round asked the client for input - else: - ... # this round returned a final result - - return result -``` - -One built-in makes a deliberate exception: the [response caching middleware](/servers/middleware) never stores an `InputRequiredToolResult`, because caching an ask would replay a stale question to a later caller. diff --git a/docs/servers/extensions.mdx b/docs/servers/extensions.mdx deleted file mode 100644 index de81d2251..000000000 --- a/docs/servers/extensions.mdx +++ /dev/null @@ -1,166 +0,0 @@ ---- -title: Server Extensions -sidebarTitle: Extensions -description: Add negotiated protocol features to a server without forking the framework. -icon: plug ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="4.0.0" /> - -An MCP extension is a protocol feature that lives outside the core spec, named by a reverse-DNS identifier and negotiated as a capability. A server advertises the extensions it implements, and a client advertises the ones it understands. That negotiation is per request: a client repeats its extension capabilities in every request's `_meta`, so a handler can always tell whether the caller opted in to this particular call. - -Honoring that opt-in is the extension's job, not the framework's. FastMCP advertises your capability and routes your methods, but it does not filter callers for you, so an extension that changes behavior must check before it acts. The [tool-call interceptor](#intercepting-tool-calls) below shows the check. - -FastMCP 4 makes extensions a first-class surface. `FastMCP.add_extension()` takes an object that can advertise a capability, serve new request methods, wrap every `tools/call`, and own resources for the life of the server. [Background tasks](/servers/tasks) are built this way, on the same public interface available to you, so a cross-cutting protocol feature becomes a plugin rather than a change to FastMCP itself. - -## Writing an extension - -Subclass `ServerExtension` and set an `identifier`. The identifier must carry a reverse-DNS prefix in `vendor-prefix/name` form, which FastMCP validates when the class is defined, so a malformed one fails immediately rather than at connection time. Everything else is optional: each contribution method has a working default, and a useful extension often overrides just one. - -Registering the extension binds it to the server and advertises its capability. The capability is advertised only while the extension is registered, and registering two extensions with the same identifier is an error. - -```python -from fastmcp import FastMCP -from fastmcp.server.extensions import ServerExtension - - -class CallCounterExtension(ServerExtension): - identifier = "com.example/call-counter" - - def __init__(self) -> None: - self.count = 0 - - -mcp = FastMCP("Demo") -mcp.add_extension(CallCounterExtension()) -``` - -Register extensions before the server starts. Adding one after the lifespan is running raises, because the extension's own lifespan could no longer run and it would end up silently half-active. - -An extension reaches the rest of the server through `self.server`, which is the `FastMCP` instance it was registered on. That is how handlers and interceptors get at the component registry, the request [`Context`](/servers/context), and the authenticated caller. - -## Advertising settings - -Some extensions need to tell the client how they are configured: a size limit, a supported mode, a flag. Override `settings()` to return a JSON-serializable dict, and it appears on the wire under `capabilities.extensions[identifier]`. The default is an empty dict, which advertises the extension with no settings attached. - -```python -from typing import Any - -from fastmcp import FastMCP -from fastmcp.server.extensions import ServerExtension - - -class UploadExtension(ServerExtension): - identifier = "com.example/uploads" - - def settings(self) -> dict[str, Any]: - return {"maxBytes": 10_000_000, "resumable": True} - - -mcp = FastMCP("Demo") -mcp.add_extension(UploadExtension()) -``` - -A client reads these alongside the capability itself, so it can adapt before making a single call. - -## Adding request methods - -An extension can serve request methods the core spec does not define. Return a `MethodBinding` from `methods()` naming the wire method, the Pydantic model its params validate against, and the handler to run. - -Extension methods are strictly additive. Binding a spec-defined method like `tools/call` raises at construction, because doing so would silently shadow the server's own handler. To change how a core method behaves, use [middleware](/servers/middleware) or the tool-call interceptor below. - -The params model should subclass `RequestParams` so `_meta` parses uniformly, and the handler receives the request context and the validated params. - -```python -from typing import Any - -from mcp.types import RequestParams -from fastmcp.server.extensions import MethodBinding, ServerExtension - - -class GetCallCountParams(RequestParams): - pass - - -class CallCounterExtension(ServerExtension): - identifier = "com.example/call-counter" - - def __init__(self) -> None: - self.count = 0 - - def methods(self) -> list[MethodBinding]: - return [ - MethodBinding( - method="callCounter/get", - params_type=GetCallCountParams, - handler=self.get_count, - ) - ] - - async def get_count(self, ctx, params: GetCallCountParams) -> dict[str, Any]: - return {"count": self.count} -``` - -Setting `protocol_versions` on a binding restricts the method to specific wire versions, and a request at any other version is rejected as `METHOD_NOT_FOUND`. Leaving it unset, the default, serves the method on every version. - -## Intercepting tool calls - -Override `intercept_tool_call()` to wrap every `tools/call` the server handles. The interceptor runs after the FastMCP middleware chain and immediately before the tool body, making it the last gate before execution. Await `call_next()` to let the call proceed, or return a result without awaiting it to short-circuit. - -Every registered interceptor runs on every tool call, including calls from clients that never advertised your extension. FastMCP does not gate this for you, so an interceptor that changes what the caller gets back must first confirm the caller opted in. `context.client_extension_settings(identifier)` returns the settings the client declared for this request, or `None` when it declared nothing. - -```python -from fastmcp import FastMCP -from fastmcp.server.extensions import ServerExtension - - -class CallCounterExtension(ServerExtension): - identifier = "com.example/call-counter" - - def __init__(self) -> None: - self.count = 0 - - async def intercept_tool_call(self, params, context, call_next): - if context.client_extension_settings(self.identifier) is None: - return await call_next() - self.count += 1 - return await call_next() - - -mcp = FastMCP("Demo") -mcp.add_extension(CallCounterExtension()) -``` - -Counting is harmless either way, so this example passes unaware callers straight through. The check becomes essential the moment an interceptor short-circuits: returning an extension-specific result to a client that never negotiated the extension hands it a shape it has no way to understand. Request methods have the same requirement, and `self.client_settings(ctx)` is the equivalent inside a handler. - -`params` holds the validated `tools/call` params, and `context` is the FastMCP `Context`, so the tool being invoked is reachable as `context.fastmcp.get_tool(params.name)` along with auth scope and the server itself. When several extensions intercept, they nest with the first-registered outermost. - -Reach for middleware when you want to observe or modify requests generally; reach for an interceptor when the behavior belongs to a negotiated capability and should exist only while that extension is registered. - -## Owning resources - -An extension that owns something with a lifecycle, such as a connection pool or a background worker, overrides `lifespan()` to return an async context manager. FastMCP enters it with the server's own [lifespan](/servers/lifespan) and exits it on shutdown, so setup and teardown stay with the extension that needs them rather than leaking into the application's startup code. - -The lifespan is entered once per runtime tree, at the root. This matters when you compose servers: extensions are served by the server they are registered on, and a mounted child's extensions do not propagate upward. The root server owns the wire, so only root-registered extensions advertise capabilities and answer methods. Register extensions on the server you actually run. - -## Client extensions - -The client half of an extension is what makes negotiation two-sided. Pass `ClientExtension` instances to `Client(extensions=...)` and each contributes its capability advertisement, its result claims, and its notification bindings to the underlying session. A claimed `call_tool` result is then resolved transparently through the extension that owns it. - -When a client needs only to say it understands an extension, without implementing behavior for it, `advertise()` produces an advertise-only entry. - -```python -from fastmcp import Client -from mcp.client import advertise - -client = Client( - "https://example.com/mcp", - extensions=[advertise("com.example/uploads", {"maxBytes": 10_000_000})], -) -``` - -Advertise only what you genuinely support: the advertisement asserts wire compatibility, and claiming an extension you have not implemented invites the server to use a feature you cannot answer. For anything behavioral, construct the real extension instead. - -Claimed result shapes are a modern-protocol feature and stay inert on a legacy connection, so an extension-aware client is still safe to point at an older server. diff --git a/docs/servers/icons.mdx b/docs/servers/icons.mdx index 065c28471..589e054f2 100644 --- a/docs/servers/icons.mdx +++ b/docs/servers/icons.mdx @@ -12,10 +12,10 @@ Icons provide visual representations for your MCP servers and components, helpin ## Icon Format -Icons use the standard MCP Icon type from the MCP protocol specification. Each icon specifies a source URL or data URI, and optionally includes MIME type, size, and theme information. +Icons use the standard MCP Icon type from the MCP protocol specification. Each icon specifies a source URL or data URI, and optionally includes MIME type and size information. ```python -from mcp.types import Icon +from fastmcp.types import Icon icon = Icon( src="https://example.com/icon.png", @@ -29,7 +29,6 @@ The fields serve different purposes: - **src**: URL or data URI pointing to the icon image - **mime_type** (optional): MIME type of the image (e.g., "image/png", "image/svg+xml") - **sizes** (optional): Array of size descriptors (e.g., ["48x48"], ["any"]) -- **theme** (optional): The UI theme the icon is designed for, `"light"` or `"dark"` ## Server Icons @@ -37,7 +36,7 @@ Add icons and a website URL to your server for display in client applications. M ```python from fastmcp import FastMCP -from mcp.types import Icon +from fastmcp.types import Icon mcp = FastMCP( name="WeatherService", @@ -66,7 +65,7 @@ Icons can be added to individual tools, resources, resource templates, and promp ### Tool Icons ```python -from mcp.types import Icon +from fastmcp.types import Icon @mcp.tool( icons=[Icon(src="https://example.com/calculator-icon.png")] @@ -111,51 +110,12 @@ def analyze_code(code: str): return f"Please analyze this code:\n\n{code}" ``` -## Theme Variants - -<VersionBadge version="4.0.0" /> - -MCP clients like VS Code and GitHub Desktop render their own interface in either a light or dark theme, and an icon designed for one can be hard to see against the other, such as a dark logo that disappears into a dark sidebar. The `theme` field on `Icon` tells a client which UI theme an icon is designed for, so the client can display the version that stays visible. - -Supply two icons with complementary `theme` values and the client picks the one that matches its current appearance: - -```python -from fastmcp import FastMCP -from mcp.types import Icon - -mcp = FastMCP( - name="WeatherService", - icons=[ - Icon(src="https://weather.example.com/icon-light.png", theme="light"), - Icon(src="https://weather.example.com/icon-dark.png", theme="dark"), - ], -) -``` - -The same field works on tools, resources, resource templates, and prompts: - -```python -from mcp.types import Icon - -@mcp.tool( - icons=[ - Icon(src="https://example.com/calculator-light.png", theme="light"), - Icon(src="https://example.com/calculator-dark.png", theme="dark"), - ] -) -def calculate_sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b -``` - -Omitting `theme` means the icon is assumed suitable for any theme. That's the right choice for a single icon with enough contrast to read clearly against both light and dark backgrounds. - ## Using Data URIs For small icons or when you want to embed the icon directly without external dependencies, use data URIs. This approach eliminates the need for hosting and ensures the icon is always available. ```python -from mcp.types import Icon +from fastmcp.types import Icon from fastmcp.utilities.types import Image # SVG icon as data URI @@ -175,7 +135,7 @@ def my_tool() -> str: FastMCP provides the `Image` utility class to convert local image files into data URIs. ```python -from mcp.types import Icon +from fastmcp.types import Icon from fastmcp.utilities.types import Image # Generate a data URI from a local image file diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx index 1a0aa96bb..c08c06b25 100644 --- a/docs/servers/middleware.mdx +++ b/docs/servers/middleware.mdx @@ -84,7 +84,7 @@ parent.mount(child, namespace="child") Requests to `child_tool` flow through the parent's `AuthMiddleware` first, then through the child's `LoggingMiddleware`. -Middleware-stored state does not automatically cross mount boundaries. If `AuthMiddleware` on the parent calls `ctx.set_state("user_id", ...)`, a tool on the child server calling `ctx.get_state("user_id")` will get `None` — each `FastMCP` instance owns its own session state store. To share state across the mount, either pass the same `session_state_store` to both servers or use `serializable=False` for request-scoped values. See [Session State](/servers/sessions) for details. +Middleware-stored state does not automatically cross mount boundaries. If `AuthMiddleware` on the parent calls `ctx.set_state("user_id", ...)`, a tool on the child server calling `ctx.get_state("user_id")` will get `None` — each `FastMCP` instance owns its own session state store. To share state across the mount, either pass the same `session_state_store` to both servers or use `serializable=False` for request-scoped values. See [State and Mounted Servers](/servers/context#state-and-mounted-servers) for details. ## Hooks @@ -98,22 +98,6 @@ Rather than processing every message identically, FastMCP provides specialized h When a client calls a tool, the middleware chain processes `on_message` first, then `on_request`, then `on_call_tool`. This hierarchy lets you target exactly the right scope—use `on_message` for logging everything, `on_request` for authentication, and `on_call_tool` for tool-specific behavior. -### What middleware sees - -<VersionBadge version="4.0.0" /> - -Dispatch begins in the SDK's middleware layer — the single point every inbound message passes through. As a result, `on_message`, `on_request`, and `on_notification` observe **every** message a client sends, including the ones that never reach a tool, resource, or prompt handler: - -- **Notifications** such as `notifications/cancelled`, `notifications/initialized`, and `notifications/progress` reach `on_message` and `on_notification`. -- **Cancellations** are observed as a `notifications/cancelled` message. The connection applies the cancellation itself and then hands the notification to your middleware. -- **Malformed or unroutable requests**—an unknown method, or a `tools/call` whose params fail validation before the tool runs—reach `on_message` and `on_request` as a raised error propagating through `call_next`, so logging and error-handling middleware record them. - -The operation hooks (`on_call_tool`, `on_list_tools`, and the rest) fire exactly once per request, and their `call_next` still returns the typed component result—a `ToolResult`, a `list[Tool]`, and so on—so a tool exception propagates through `on_call_tool`, `on_request`, and `on_message` exactly where error, logging, and timing middleware expect it. - -#### Multi-round tool calls - -A guard tool asks the client for input by returning an `InputRequiredResult` (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Each round of a multi-round call is a complete request→response cycle that runs the **full middleware chain**: `on_call_tool` fires once per round, and on an asking round `call_next` returns the ask as that round's ordinary result value—an `InputRequiredToolResult`, a `ToolResult` subclass. Nothing is raised and nothing is held open, so default middleware completes normally on every round (logging logs the ask, timing times it, error handling does not fire—an ask is a legitimate result, not an error). Middleware that needs to treat an ask differently identifies it with an `isinstance(result, InputRequiredToolResult)` check; see [Middleware and multi-round calls](/servers/elicitation#middleware) for a worked example. - ### Hook Signature Every hook follows the same pattern: @@ -278,54 +262,29 @@ async def on_list_prompts(self, context: MiddlewareContext, call_next): <VersionBadge version="2.13.0" /> -Called when a client connects and initializes the session. Middleware can reject the client before `call_next()` raises an error response, or inspect and modify the `InitializeResult` after `call_next()` returns. - -The request params carry the identity the client declared for itself on `client_info`, which makes this the natural place to gate access by client. Note that these fields are snake_case: the MCP wire format spells it `clientInfo`, but the Python model exposes `client_info` and treats the camelCase form as a serialization alias only. +Called when a client connects and initializes the session. This hook cannot modify the initialization response. ```python from fastmcp.exceptions import McpError async def on_initialize(self, context: MiddlewareContext, call_next): - client_name = context.message.params.client_info.name + client_info = context.message.params.get("clientInfo", {}) + client_name = client_info.get("name", "unknown") # Reject before call_next to send error to client if client_name == "blocked-client": raise McpError(code=-32000, message="Client not supported") - result = await call_next(context) + await call_next(context) print(f"Client {client_name} initialized") - return result ``` -**Returns:** `InitializeResult | None` — The value you return is what gets serialized to the client, so modifying the result from `call_next()` changes what the client receives, including fields like `instructions` and `server_info`. - -```python -async def on_initialize(self, context: MiddlewareContext, call_next): - result = await call_next(context) - result.instructions = "Custom instructions for this client" - return result -``` +**Returns:** `None` — The initialization response is handled internally by the MCP protocol. <Warning> -Rejection works only **before** `call_next()`. Raising `McpError` afterward logs the error without sending it — the client still receives a successful initialize response. +Raising `McpError` after `call_next()` will only log the error, not send it to the client. The response has already been sent. Always reject **before** `call_next()`. </Warning> -#### on_discover - -Called when a modern client negotiates through `server/discover`. Core discovery responses are returned as `DiscoverResult`; extension-owned result types are returned as dictionaries and should be passed through unless the middleware handles that extension. - -```python -from mcp_types import DiscoverResult - -async def on_discover(self, context, call_next): - result = await call_next(context) - if not isinstance(result, DiscoverResult): - return result - return result.model_copy(update={"instructions": "Custom instructions"}) -``` - -Fields such as `supported_versions`, `capabilities`, and cache policy should only be changed when the server's public behavior also changes. - ### Raw Handler For complete control over all messages, override `__call__` instead of individual hooks: @@ -366,7 +325,7 @@ async def on_request(self, context: MiddlewareContext, call_next): return await call_next(context) ``` -For HTTP-specific data (headers, client IP) when using HTTP transports, see [HTTP Request](/servers/dependency-injection#http-request). +For HTTP-specific data (headers, client IP) when using HTTP transports, see [HTTP Requests](/servers/context#http-requests). ## Built-in Middleware @@ -394,7 +353,7 @@ mcp.add_middleware(LoggingMiddleware( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `include_payloads` | `bool` | `False` | Log request/response content | -| `max_payload_length` | `int` | `1000` | Truncate payloads beyond this length | +| `max_payload_length` | `int` | `500` | Truncate payloads beyond this length | | `logger` | `Logger` | module logger | Custom logger instance | ### Timing @@ -549,7 +508,7 @@ mcp.add_middleware(ErrorHandlingMiddleware( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `include_traceback` | `bool` | `False` | Include stack traces in logs | -| `transform_errors` | `bool` | `True` | Convert exceptions to MCP errors | +| `transform_errors` | `bool` | `False` | Convert exceptions to MCP errors | | `error_callback` | `Callable` | `None` | Custom callback on errors | For automatic retries: @@ -835,7 +794,7 @@ def get_user_data(ctx: Context) -> str: return f"Data for user: {user_id}" ``` -See [Request State](/servers/context#request-state) for details. +See [Context State Management](/servers/context#state-management) for details. ### Constructor Parameters diff --git a/docs/servers/pagination.mdx b/docs/servers/pagination.mdx index b78f09bb1..c23e296bc 100644 --- a/docs/servers/pagination.mdx +++ b/docs/servers/pagination.mdx @@ -14,7 +14,7 @@ When a server exposes many tools, resources, or prompts, returning them all in a ## Server Configuration -By default, FastMCP servers return all components in a single response for backward compatibility. To enable pagination, set the `list_page_size` parameter when creating your server. This value must be a positive integer and determines the maximum number of items returned per page across all list operations. +By default, FastMCP servers return all components in a single response for backward compatibility. To enable pagination, set the `list_page_size` parameter when creating your server. This value determines the maximum number of items returned per page across all list operations. ```python from fastmcp import FastMCP diff --git a/docs/servers/progress.mdx b/docs/servers/progress.mdx index 7dadb73a4..9600a05ce 100644 --- a/docs/servers/progress.mdx +++ b/docs/servers/progress.mdx @@ -11,7 +11,7 @@ Progress reporting allows MCP tools to notify clients about the progress of long ## Basic Usage -Use `ctx.report_progress()` to send progress updates to the client. The method accepts a `progress` value representing how much work is complete, an optional `total` representing the full scope of work, and an optional `message` with human-readable status text. +Use `ctx.report_progress()` to send progress updates to the client. The method accepts a `progress` value representing how much work is complete, and an optional `total` representing the full scope of work. ```python from fastmcp import FastMCP, Context @@ -26,7 +26,7 @@ async def process_items(items: list[str], ctx: Context) -> dict: results = [] for i, item in enumerate(items): - await ctx.report_progress(progress=i, total=total, message=f"Processing {item}") + await ctx.report_progress(progress=i, total=total) await asyncio.sleep(0.1) results.append(item.upper()) diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index 1986a50bf..b5cf2f6e9 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -95,6 +95,10 @@ def data_analysis_prompt( A set of strings used to categorize the prompt. These can be used by the server and, in some cases, by clients to filter or group available prompts. </ParamField> +<ParamField body="enabled" type="bool" default="True"> + <Warning>Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead.</Warning> + A boolean to enable or disable the prompt. See [Component Visibility](#component-visibility) for the recommended approach. +</ParamField> <ParamField body="icons" type="list[Icon] | None"> <VersionBadge version="2.13.0" /> @@ -310,7 +314,7 @@ return PromptResult("Please help me with this task") # auto-converts to single Messages to return. Strings are wrapped as a single user Message. </ParamField> <ParamField body="description" type="str | None"> - Optional description of this rendered prompt result. Plain `str` and `list[Message | str]` returns inherit the prompt definition description automatically, but an explicit `PromptResult` uses the description you pass here and otherwise leaves it unset. + Optional description of the prompt result. If not provided, defaults to the prompt's docstring. </ParamField> <ParamField body="meta" type="dict[str, Any] | None"> Result-level metadata, included in the MCP response's `_meta` field. Use this for runtime metadata like categorization, priority, or other client-specific data. @@ -363,7 +367,7 @@ def internal_prompt() -> str: return "Internal system prompt" # Disable specific prompts by key -mcp.disable(names={"internal_prompt"}) +mcp.disable(keys={"prompt:internal_prompt"}) # Disable prompts by tag mcp.disable(tags={"internal"}) @@ -430,32 +434,28 @@ def example_prompt() -> str: # These operations trigger notifications: mcp.add_prompt(example_prompt) # Sends prompts/list_changed notification -mcp.disable(names={"example_prompt"}) # Sends prompts/list_changed notification -mcp.enable(names={"example_prompt"}) # Sends prompts/list_changed notification +mcp.disable(keys={"prompt:example_prompt"}) # Sends prompts/list_changed notification +mcp.enable(keys={"prompt:example_prompt"}) # Sends prompts/list_changed notification ``` Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications. Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their prompt lists or update their interfaces. -## Requesting Input - -A prompt can ask the client for information before it renders. On an MCP 2026-07-28 connection, return an `InputRequiredResult` describing what you need; the client answers and re-issues the `prompts/get`, and your function runs again with the answer on `ctx.input_responses`. See [Elicitation](/servers/elicitation#prompts-and-resources) for the full pattern. - ## Server Behavior ### Duplicate Prompts <VersionBadge version="2.1.0" /> -You can configure how the FastMCP server handles attempts to register the same prompt twice. Identity is the component's type, name, and version together, so a prompt may share a name with a tool, and two versions of one prompt coexist. The `on_duplicate` setting covers every component type, so it applies to prompts alongside tools and resources. +You can configure how the FastMCP server handles attempts to register multiple prompts with the same name. Use the `on_duplicate_prompts` setting during `FastMCP` initialization. ```python from fastmcp import FastMCP mcp = FastMCP( name="PromptServer", - on_duplicate="error" # Raise an error on an exact duplicate + on_duplicate_prompts="error" # Raise an error if a prompt name is duplicated ) @mcp.prompt diff --git a/docs/servers/providers/local.mdx b/docs/servers/providers/local.mdx index 147ef7e59..86726655a 100644 --- a/docs/servers/providers/local.mdx +++ b/docs/servers/providers/local.mdx @@ -124,10 +124,10 @@ def get_status() -> str: mcp.disable(tags={"admin"}) # Or only enable specific tools -mcp.enable(names={"get_status"}, only=True) +mcp.enable(keys={"tool:get_status"}, only=True) ``` -See [Visibility](/servers/visibility) for the full documentation on names, tags, keys, allowlist mode, and provider-level control. +See [Visibility](/servers/visibility) for the full documentation on keys, tags, allowlist mode, and provider-level control. ## Standalone LocalProvider diff --git a/docs/servers/providers/overview.mdx b/docs/servers/providers/overview.mdx index 2f23f76a5..d3e3e4e5f 100644 --- a/docs/servers/providers/overview.mdx +++ b/docs/servers/providers/overview.mdx @@ -57,9 +57,9 @@ Transforms can be added to individual providers (affecting just that source) or ## Provider Order -When a client requests a component by name or URI, FastMCP queries providers and returns the highest matching version across the providers that have it. For unversioned components, or for components with equal versions, provider registration order is the tie-breaker. +When a client requests a tool, FastMCP queries providers in registration order. The first provider that has the tool handles the request. -`LocalProvider` is always registered first, so your decorator-defined components take precedence over equal-version components from mounted or proxied providers. Additional providers are registered in the order you add them. +`LocalProvider` is always first, so your decorator-defined tools take precedence. Additional providers are queried in the order you added them. This means if two providers have a tool with the same name, the first one wins. ## When to Care About Providers @@ -70,6 +70,12 @@ When a client requests a component by name or URI, FastMCP queries providers and - [Proxy a remote server](/servers/providers/proxy) through yours - [Control visibility state](/servers/visibility) of components - [Build dynamic sources](/servers/providers/custom) like database-backed tools -- [Transform components](/servers/transforms/transforms) to namespace, rename, or modify them -The decorators you already use are themselves a provider: [`LocalProvider`](/servers/providers/local) is what backs `@mcp.tool` and its siblings. +## Next Steps + +- [Local](/servers/providers/local) - How decorators work +- [Mounting](/servers/composition) - Compose servers together +- [Proxying](/servers/providers/proxy) - Connect to remote servers +- [Transforms](/servers/transforms/transforms) - Namespace, rename, and modify components +- [Visibility](/servers/visibility) - Control which components clients can access +- [Custom](/servers/providers/custom) - Build your own providers diff --git a/docs/servers/providers/proxy.mdx b/docs/servers/providers/proxy.mdx index 1ff116bbd..9d64b6148 100644 --- a/docs/servers/providers/proxy.mdx +++ b/docs/servers/providers/proxy.mdx @@ -60,9 +60,11 @@ To mount a proxy inside another FastMCP server, see [Mounting External Servers]( ## Connection Semantics -FastMCP proxies are lazy bridges. Creating the proxy object and starting the local server do not contact the upstream server. During client negotiation, the proxy makes a best-effort request for optional server metadata using the backend client's existing lifecycle and negotiation mode; an unavailable backend does not prevent the client from connecting to the proxy. +FastMCP proxies are lazy bridges. Creating the proxy object and starting the local server do not contact the upstream server. The upstream connection begins when an MCP client sends an `initialize` request to the proxy. -Subsequent MCP requests such as `ping`, `tools/list`, `resources/list`, `prompts/list`, tool calls, resource reads, sampling, elicitation, logging, and progress connect to the backend as needed. Component provider failures follow `provider_error_strategy`: the default `"warn"` logs and skips a failed provider, while `"raise"` reports the failure to the client. +During initialization, the proxy initializes the upstream server before responding locally. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or upstream authentication cannot complete, the proxy initialization fails. This keeps the local proxy's connection status aligned with the upstream server it represents. + +After initialization, the proxy forwards MCP requests such as `ping`, `tools/list`, `resources/list`, `prompts/list`, tool calls, resource reads, sampling, elicitation, logging, and progress through the upstream client. ## Transport Bridging @@ -168,80 +170,6 @@ backend = ProxyClient( ) ``` -### Tool Results Are Relayed, Not Inspected - -<VersionBadge version="4.0.0" /> - -A proxy passes a backend's tool results through untouched, including results that don't match the output schema the backend advertised. Deciding whether a server honored its own contract belongs to the client consuming the result, and that client validates for itself. - -This matters when a backend's declared schema is subtly wrong — an enum missing a variant it actually returns, say. A proxy that enforced the schema would replace the backend's working response with an error of its own, and the client would never see what the backend actually said. - -```python -from fastmcp import Client -from fastmcp.server import create_proxy - -proxy = create_proxy("backend_server.py") - -async with Client(proxy) as client: - # The backend's response arrives as the backend sent it. If it violates - # the backend's own output schema, this client raises — its decision. - result = await client.call_tool("get_status") -``` - -Skipping the check also avoids a `tools/list` round trip to the backend on every proxied call, since validation would need the backend's schemas and a proxy builds a fresh connection per request. - -### Protocol Era Mirroring - -<VersionBadge version="4.0.0" /> - -A proxy is a server on its front and a client on its back, and the two MCP protocol eras have mutually exclusive interaction models on a single session. On the handshake era (≤2025-11-25) the backend can push server-initiated requests — sampling, elicitation, roots — which the proxy forwards to your client. On the modern era (2026-07-28) those pushes are gone; a backend guard tool instead returns an input request that the proxy relays back as a result. A single proxy session speaks one era, so the whole chain has to agree end-to-end. - -By default the proxy relays the era: whatever era your client negotiates on the front, the proxy negotiates the same era on its backend connection, per request. A handshake client reaches a handshake backend, so server-initiated forwarding works; a modern client reaches a modern backend, so a guard tool's input request round-trips. Different clients hitting the same proxy each get a backend session in their own era — the eras never cross. - -```python -from fastmcp import Client -from fastmcp.server import create_proxy - -# No mode: the backend mirrors each client's negotiated era. -proxy = create_proxy("backend_server.py") - -# A handshake client gets a handshake backend (push-forwarding works). -async with Client(proxy, mode="legacy") as client: - ... - -# A modern client gets a modern backend (guard tools round-trip). -async with Client(proxy, mode="auto") as client: - ... -``` - -Passing an explicit `mode` pins the backend to one era regardless of the client: - -```python -# Always negotiate the modern era upstream, whatever the client speaks. -proxy = create_proxy("backend_server.py", mode="auto") -``` - -Pinning breaks the end-to-end era agreement, so reserve it for a backend that only speaks one era. When the client's era and the pinned backend era disagree on a feature — a modern client asking for a guard round-trip against a handshake-pinned backend, say — the mismatch surfaces through the normal era gates rather than silently degrading. Mirroring applies to proxies created from a target the proxy connects itself (a URL, path, config, or `FastMCP` instance); when you hand `create_proxy` an already-configured `Client`, that client carries its own mode and mirroring does not override it. - -A multi-server configuration adds a hop: FastMCP mounts one proxy per configured server onto a router, and your client talks to that router rather than to any backend directly. The era carries through the whole depth, so each real backend negotiates the era your client did — not just the router in front of them. - -```python -proxy = create_proxy( - { - "mcpServers": { - "weather": {"url": "https://weather.example.com/mcp"}, - "calendar": {"url": "https://calendar.example.com/mcp"}, - } - } -) -``` - -A modern client here reaches both `weather` and `calendar` on modern sessions, so a guard tool on either one round-trips end to end. An explicit `mode` pins every backend in the configuration, the same way it pins a single one. - -### Request Metadata - -Request `_meta` follows the same connection boundary. Progress tokens, tracing, task state, and application or vendor metadata pass through the proxy to the backend. The connection-owned keys — protocol version, client identity, and client capabilities — never copy from the frontend connection: a modern backend session stamps its own negotiated values, and a handshake-era backend receives none. This holds even when the two connections negotiate different eras, such as a modern client reaching a handshake-only backend through an explicit `mode`. - ## Configuration-Based Proxies <VersionBadge version="2.4.0" /> @@ -386,28 +314,6 @@ Only reuse sessions when you know the backend is stateless (e.g. stateless HTTP) ## Advanced Usage -### Forwarding Server Metadata - -Add `ProxyMetadataMiddleware` when a gateway built with `ProxyProvider` should also expose backend instructions and namespaced `_meta`: - -```python -from fastmcp import FastMCP -from fastmcp.server.providers.proxy import ( - ProxyClient, - ProxyMetadataMiddleware, - ProxyProvider, -) - -backend = ProxyProvider(lambda: ProxyClient("http://backend:8000/mcp", mode="auto")) -gateway = FastMCP( - "Controlled Gateway", - providers=[backend], - middleware=[ProxyMetadataMiddleware(backend)], -) -``` - -By default the gateway keeps its own `serverInfo`; pass `identity="upstream"` to use the backend identity when available. Frontend instructions and `_meta` values win on collisions. The middleware never copies upstream protocol versions, connection metadata, capabilities, cache policy, `resultType`, or unknown top-level fields. If the backend is unavailable, the client can still connect without its optional metadata. - ### FastMCPProxy Class For explicit session control, use `FastMCPProxy` directly: diff --git a/docs/servers/providers/skills.mdx b/docs/servers/providers/skills.mdx index 01214d3d4..0f8eff5e7 100644 --- a/docs/servers/providers/skills.mdx +++ b/docs/servers/providers/skills.mdx @@ -38,10 +38,10 @@ Each subdirectory containing a `SKILL.md` file becomes a discoverable skill. Cli from fastmcp import Client async with Client(mcp) as client: - # List each skill's main file and manifest + # List all skill resources resources = await client.list_resources() for r in resources: - print(r.uri) # skill://my-skill/SKILL.md, skill://my-skill/_manifest + print(r.uri) # skill://my-skill/SKILL.md, skill://my-skill/_manifest, ... # Read a skill's main instruction file result = await client.read_resource("skill://my-skill/SKILL.md") diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 13a5d986a..5e514d39d 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -78,7 +78,7 @@ mcp = FastMCP(name="DataServer") ) def get_application_status() -> str: """Internal function description (ignored if description is provided above).""" - return json.dumps({"status": "ok", "uptime": 12345, "version": "2.1"}) + return json.dumps({"status": "ok", "uptime": 12345, "version": mcp.settings.version}) ``` <Card icon="code" title="@resource Decorator Arguments"> @@ -90,10 +90,6 @@ def get_application_status() -> str: A human-readable name. If not provided, defaults to function name </ParamField> -<ParamField body="title" type="str | None"> - A human-readable display title for the resource or template -</ParamField> - <ParamField body="description" type="str | None"> Explanation of the resource. If not provided, defaults to docstring </ParamField> @@ -106,6 +102,11 @@ def get_application_status() -> str: A set of strings used to categorize the resource. These can be used by the server and, in some cases, by clients to filter or group available resources. </ParamField> +<ParamField body="enabled" type="bool" default="True"> + <Warning>Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead.</Warning> + A boolean to enable or disable the resource. See [Component Visibility](#component-visibility) for the recommended approach. +</ParamField> + <ParamField body="icons" type="list[Icon] | None"> <VersionBadge version="2.13.0" /> @@ -143,16 +144,14 @@ For decorating instance or class methods, use the standalone `@resource` decorat ### Return Values -Resource functions can return these supported shapes: +Resource functions must return one of three types: - **`str`**: Sent as `TextResourceContents` (with `mime_type="text/plain"` by default). - **`bytes`**: Base64 encoded and sent as `BlobResourceContents`. You should specify an appropriate `mime_type` (e.g., `"image/png"`, `"application/octet-stream"`). -- **JSON-native values** (`dict`, `list`, `tuple`, numbers, booleans, or `None`): Serialized to JSON text, keeping the resource's declared MIME type — which is `text/plain` unless you set one. Pass `mime_type="application/json"` on the decorator whenever clients rely on the content type to parse the payload. -- **`list[ResourceContent]`**: Multiple content items with per-item MIME types and metadata. -- **`ResourceResult`**: Full control over contents, MIME types, and result-level metadata. See [ResourceResult](#resourceresult) below. +- **`ResourceResult`**: Full control over contents, MIME types, and metadata. See [ResourceResult](#resourceresult) below. <Note> -For custom classes that are not JSON-native, return a `ResourceResult` or wrap values in a `ResourceContent` list so serialization and MIME types are explicit. +To return structured data like dicts or lists, serialize them to JSON strings using `json.dumps()`. This explicit approach ensures your type checker catches errors during development rather than at runtime when a client reads the resource. </Note> #### ResourceResult @@ -232,7 +231,7 @@ def get_public(): return "public" def get_secret(): return "secret" # Disable specific resources by key -mcp.disable(names={"data://secret"}) +mcp.disable(keys={"resource:data://secret"}) # Disable resources by tag mcp.disable(tags={"internal"}) @@ -370,8 +369,8 @@ def example_resource() -> str: # These operations trigger notifications: mcp.add_resource(example_resource) # Sends resources/list_changed notification -mcp.disable(names={"data://example"}) # Sends resources/list_changed notification -mcp.enable(names={"data://example"}) # Sends resources/list_changed notification +mcp.disable(keys={"resource:data://example"}) # Sends resources/list_changed notification +mcp.enable(keys={"resource:data://example"}) # Sends resources/list_changed notification ``` Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications. @@ -525,8 +524,6 @@ Note that like regular parameters, each wildcard parameter must still be a named #### Path Security -<VersionBadge version="4.0.0" /> - Template parameters are extracted from the request URI and decoded before your function receives them, so a path-traversal payload like `../` or an absolute path can reach a handler that builds filesystem paths or upstream URLs. FastMCP screens every templated resource's parameter values **before the handler runs**, and this screening is **on by default**. By default, a parameter value is rejected if its `..` path segments would escape the value's own starting depth, if it looks like an absolute path, or if it contains a null byte. A rejected read surfaces a clean "resource not found" error to the client and logs the reason at debug level, so the failing parameter and policy are never revealed on the wire. @@ -783,24 +780,20 @@ def get_data_by_id(id: str) -> dict: When `mask_error_details=True`, only error messages from `ResourceError` will include details, other exceptions will be converted to a generic message. -## Requesting Input - -A resource or resource template can ask the client for information before it produces content. On an MCP 2026-07-28 connection, return an `InputRequiredResult` describing what you need; the client answers and re-issues the `resources/read`, and your function runs again with the answer on `ctx.input_responses`. See [Elicitation](/servers/elicitation#prompts-and-resources) for the full pattern. - ## Server Behavior ### Duplicate Resources <VersionBadge version="2.1.0" /> -You can configure how the FastMCP server handles attempts to register the same resource or template twice. Identity is the component's type, URI, and version together, so two versions of one resource coexist and only an exact repeat collides. The `on_duplicate` setting covers every component type, so it applies to resources and templates alongside tools and prompts. +You can configure how the FastMCP server handles attempts to register multiple resources or templates with the same URI. Use the `on_duplicate_resources` setting during `FastMCP` initialization. ```python from fastmcp import FastMCP mcp = FastMCP( name="ResourceServer", - on_duplicate="error" # Raise an error on an exact duplicate + on_duplicate_resources="error" # Raise error on duplicates ) @mcp.resource("data://config") diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx index e3448bcbe..d19a05c01 100644 --- a/docs/servers/sampling.mdx +++ b/docs/servers/sampling.mdx @@ -1,7 +1,7 @@ --- title: Sampling sidebarTitle: Sampling -description: Generate text from a FastMCP server — by calling an LLM directly, or by asking the client to sample. +description: Request LLM text generation from the client or a configured provider through the MCP context. icon: robot --- @@ -10,102 +10,578 @@ import { VersionBadge } from "/snippets/version-badge.mdx" <VersionBadge version="2.0.0" /> <Warning> -**`ctx.sample()` and `ctx.sample_step()` were removed in FastMCP 4.** The modern MCP protocol gives a server no channel to push a request to its client, so there is nothing left for those methods to do. +**Sampling is deprecated and will be removed in a future FastMCP release.** -To build a server that uses sampling, stay on [FastMCP 3.x](/v3/servers/sampling). On FastMCP 4, generate by [calling an LLM directly](#calling-an-llm-directly), or [ask the caller's model](#asking-the-callers-model) when borrowing their model is the point. +`ctx.sample()` and `ctx.sample_step()` rely on server-initiated `createMessage` +requests, which MCP removed as of the 2026-07-28 protocol (SEP-2577). They work +only on session-based (handshake-era) connections; on a 2026-07-28 connection +they raise a clear error rather than reaching the client. + +**Migration:** call an LLM directly from your server using your own API key and +provider SDK instead of borrowing the client's model. There is no drop-in +replacement on modern connections — this architectural shift is the intended +answer. </Warning> -A tool that needs text generated calls a model to get it, and in FastMCP 4 that call is ordinary Python: your server holds an API key, creates a provider client, and awaits a completion inside the tool. No protocol is involved, so the tool behaves the same for every client — including the many that never implemented sampling at all. +LLM sampling allows your MCP tools to request text generation from an LLM during execution. This enables tools to leverage AI capabilities for analysis, generation, reasoning, and more—without the client needing to orchestrate multiple calls. -The alternative is to ask the caller. Sampling borrows *the caller's* model — their provider, their credentials, their bill — by returning a request for a completion that the client fulfils and hands back. Every ask costs a full round trip, so it earns its keep when using the caller's model is the point, and rarely otherwise. +By default, sampling requests are routed to the client's LLM. You can also configure a fallback handler to use a specific provider (like OpenAI) when the client doesn't support sampling, or to always use your own LLM regardless of client capabilities. -## Calling an LLM directly +## Overview -Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. You choose the model, control the prompt, see the token usage, and can test the tool with no client attached. +The simplest use of sampling is passing a prompt string to `ctx.sample()`. The method sends the prompt to the LLM, waits for the complete response, and returns a `SamplingResult`. You can access the generated text through the `.text` attribute. ```python -import anthropic -from fastmcp import FastMCP - -mcp = FastMCP("Summarizer") -llm = anthropic.AsyncAnthropic() +from fastmcp import FastMCP, Context +mcp = FastMCP() @mcp.tool -async def summarize(text: str) -> str: - """Summarize a document in two sentences.""" - response = await llm.messages.create( - model="claude-sonnet-4-5", - max_tokens=512, - system="Summarize the user's text in exactly two sentences.", - messages=[{"role": "user", "content": text}], +async def summarize(content: str, ctx: Context) -> str: + """Generate a summary of the provided content.""" + result = await ctx.sample(f"Please summarize this:\n\n{content}") + return result.text or "" +``` + +The `SamplingResult` also provides `.result` (identical to `.text` for plain text responses) and `.history` containing the full message exchange—useful if you need to continue the conversation or debug the interaction. + +### System Prompts + +System prompts let you establish the LLM's role and behavioral guidelines before it processes your request. This is useful for controlling tone, enforcing constraints, or providing context that shouldn't clutter the user-facing prompt. + +````python +from fastmcp import FastMCP, Context + +mcp = FastMCP() + +@mcp.tool +async def generate_code(concept: str, ctx: Context) -> str: + """Generate a Python code example for a concept.""" + result = await ctx.sample( + messages=f"Write a Python example demonstrating '{concept}'.", + system_prompt=( + "You are an expert Python programmer. " + "Provide concise, working code without explanations." + ), + temperature=0.7, + max_tokens=300 ) - return response.content[0].text -``` + return f"```python\n{result.text}\n```" +```` -Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is ordinary application code, the concerns around it are ordinary too: retries, timeouts, caching, and cost accounting go wherever you want them rather than being negotiated across a protocol boundary. A tool that chains several generations pays nothing extra for the second and third, where asking the caller would pay a full round trip for each. +The `temperature` parameter controls randomness—higher values (up to 1.0) produce more varied outputs, while lower values make responses more deterministic. The `max_tokens` parameter limits response length. -## Asking the caller's model +### Model Preferences -A tool asks for a completion by returning an `InputRequiredResult` whose `input_requests` map holds a `CreateMessageRequest` under a key you choose. That result completes the round normally. The client runs the completion, then re-issues the same `call_tool` with the answer attached, and your tool reads it from `ctx.input_responses` under the same key — a `CreateMessageResult`. Because the tool runs from the top on every round, the presence of `ctx.input_responses` is what tells the two rounds apart: `None` on the first call, populated on the continuation. - -`fastmcp.Client` drives that loop for you and answers from the [`sampling_handler`](/clients/sampling) it already has, so a client written for a handshake-era server needs no extra wiring to satisfy a modern tool that asks this way. +Model preferences let you hint at which LLM the client should use for a request. You can pass a single model name or a list of preferences in priority order. These are hints rather than requirements—the actual model used depends on what the client has available. ```python -from fastmcp import Context, FastMCP -from mcp.types import ( - CreateMessageRequest, - CreateMessageRequestParams, - CreateMessageResult, - InputRequiredResult, - SamplingMessage, - TextContent, -) - -mcp = FastMCP("Research") +from fastmcp import FastMCP, Context +mcp = FastMCP() @mcp.tool -async def ask_the_caller(question: str, ctx: Context) -> str | InputRequiredResult: - """Put a question to the caller's model and report what it answered.""" - responses = ctx.input_responses - if responses is None: - return InputRequiredResult( - result_type="input_required", - input_requests={ - "answer": CreateMessageRequest( - method="sampling/createMessage", - params=CreateMessageRequestParams( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=question), - ) - ], - max_tokens=100, - ), - ) - }, - ) - - answer = responses["answer"] - if isinstance(answer, CreateMessageResult) and isinstance( - answer.content, TextContent - ): - return answer.content.text - return "The client returned no completion." +async def technical_analysis(data: str, ctx: Context) -> str: + """Analyze data using a reasoning-focused model.""" + result = await ctx.sample( + messages=f"Analyze this data:\n\n{data}", + model_preferences=["claude-opus-4-5", "gpt-5-2"], + temperature=0.2, + ) + return result.text or "" ``` -Returning an `InputRequiredResult` needs a `2026-07-28` connection, and FastMCP names the era mismatch if an older client reaches the tool; the conformance suite exercises this route on that version. The map can carry several requests at once and mix kinds — a sampling request beside an elicitation or a roots request — with each answer coming back under its own key. [Elicitation](/servers/elicitation#sampling-and-roots) covers the mechanics of the pattern in full, including how to carry state across rounds. +Use model preferences when different tasks benefit from different model characteristics. Creative writing might prefer faster models with higher temperature, while complex analysis might benefit from larger reasoning-focused models. -## The removed methods +### Multi-Turn Conversations -`Context` has no `sample()` and no `sample_step()`; touching either raises `AttributeError` on every protocol era, rather than failing at runtime only against modern clients. `FastMCP()` accepts neither `sampling_handler=` nor `sampling_handler_behavior=`, and naming one raises a `TypeError` that points at the migration. +For requests that need conversational context, construct a list of `SamplingMessage` objects representing the conversation history. Each message has a `role` ("user" or "assistant") and `content` (a `TextContent` object). -The reason is the distinction MCP draws between telling and asking. A notification is fire-and-forget: the server emits it and moves on, and it travels down the response stream the caller already opened, so nothing has to be held open on the server's behalf. That is why [logging](/servers/logging) is untouched by any of this — `ctx.info()` and its siblings reach the client mid-call on every era. Sampling is the other kind. `sampling/createMessage` goes out and the caller must answer before the tool can continue, which needs a live, addressable connection the server can reach into, and the `2026-07-28` revision removed server-initiated requests ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)) precisely because a stateless protocol has no such thing. +```python +from fastmcp.types import SamplingMessage, TextContent +from fastmcp import FastMCP, Context -What the protocol removed is the pushing, not the asking, so the capability survives in the shape described above. Keeping `ctx.sample()` alongside it would mean shipping a method whose outcome against a default client — one that negotiates the modern era — is a runtime failure. +mcp = FastMCP() + +@mcp.tool +async def contextual_analysis(query: str, data: str, ctx: Context) -> str: + """Analyze data with conversational context.""" + messages = [ + SamplingMessage( + role="user", + content=TextContent(type="text", text=f"Here's my data: {data}"), + ), + SamplingMessage( + role="assistant", + content=TextContent(type="text", text="I see the data. What would you like to know?"), + ), + SamplingMessage( + role="user", + content=TextContent(type="text", text=query), + ), + ] + result = await ctx.sample(messages=messages) + return result.text or "" +``` + +The LLM receives the full conversation thread and responds with awareness of the preceding context. + +### Fallback Handlers + +Client support for sampling is optional—some clients may not implement it. To ensure your tools work regardless of client capabilities, configure a `sampling_handler` that sends requests directly to an LLM provider. + +FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format. <Note> -Servers on FastMCP 3 still have `ctx.sample()` and `ctx.sample_step()`, documented in the [FastMCP 3 sampling guide](/v3/servers/sampling). Nothing changes for them until they upgrade. +Install handlers with `pip install fastmcp[openai]` or `pip install fastmcp[anthropic]`. </Note> + +```python +from fastmcp import FastMCP +from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler + +server = FastMCP( + name="My Server", + sampling_handler=OpenAISamplingHandler(default_model="gpt-4o-mini"), + sampling_handler_behavior="fallback", +) +``` + +The `sampling_handler_behavior` parameter controls when the handler is used: + +- **`"fallback"`** (default): Use the handler only when the client doesn't support sampling. This lets capable clients use their own LLM while ensuring your tools still work with clients that lack sampling support. +- **`"always"`**: Always use the handler, bypassing the client entirely. Use this when you need guaranteed control over which LLM processes requests—for cost control, compliance requirements, or when specific model characteristics are essential. + +## Structured Output + +<VersionBadge version="2.14.1" /> + +When you need validated, typed data instead of free-form text, use the `result_type` parameter. FastMCP ensures the LLM returns data matching your type, handling validation and retries automatically. + +The `result_type` parameter accepts Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. When you specify a result type, FastMCP automatically creates a `final_response` tool that the LLM calls to provide its response. If validation fails, the error is sent back to the LLM for retry. + +```python +from pydantic import BaseModel +from fastmcp import FastMCP, Context + +mcp = FastMCP() + +class SentimentResult(BaseModel): + sentiment: str + confidence: float + reasoning: str + +@mcp.tool +async def analyze_sentiment(text: str, ctx: Context) -> SentimentResult: + """Analyze text sentiment with structured output.""" + result = await ctx.sample( + messages=f"Analyze the sentiment of: {text}", + result_type=SentimentResult, + ) + return result.result # A validated SentimentResult object +``` + +When you call this tool, the LLM returns a structured response that FastMCP validates against your Pydantic model. You access the validated object through `result.result`, while `result.text` contains the JSON representation. + +### Structured Output with Tools + +Combine structured output with tools for agentic workflows that return validated data. The LLM uses your tools to gather information, then returns a response matching your type. + +```python +from pydantic import BaseModel +from fastmcp import FastMCP, Context + +mcp = FastMCP() + +def search(query: str) -> str: + """Search the web for information.""" + return f"Results for: {query}" + +def fetch_url(url: str) -> str: + """Fetch content from a URL.""" + return f"Content from: {url}" + +class ResearchResult(BaseModel): + summary: str + sources: list[str] + confidence: float + +@mcp.tool +async def research(topic: str, ctx: Context) -> ResearchResult: + """Research a topic and return structured findings.""" + result = await ctx.sample( + messages=f"Research: {topic}", + tools=[search, fetch_url], + result_type=ResearchResult, + ) + return result.result +``` + +<Note> +Structured output with automatic validation only applies to `sample()`. With `sample_step()`, you must manage structured output yourself. +</Note> + +## Tool Use + +<VersionBadge version="2.14.1" /> + +Sampling with tools enables agentic workflows where the LLM can call functions to gather information before responding. This implements [SEP-1577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577), allowing the LLM to autonomously orchestrate multi-step operations. + +Pass Python functions to the `tools` parameter, and FastMCP handles the execution loop automatically—calling tools, returning results to the LLM, and continuing until the LLM provides a final response. + +### Defining Tools + +Define regular Python functions with type hints and docstrings. FastMCP extracts the function's name, docstring, and parameter types to create tool schemas that the LLM can understand. + +```python +from fastmcp import FastMCP, Context + +def search(query: str) -> str: + """Search the web for information.""" + return f"Results for: {query}" + +def get_time() -> str: + """Get the current time.""" + from datetime import datetime + return datetime.now().strftime("%H:%M:%S") + +mcp = FastMCP() + +@mcp.tool +async def research(question: str, ctx: Context) -> str: + """Answer questions using available tools.""" + result = await ctx.sample( + messages=question, + tools=[search, get_time], + ) + return result.text or "" +``` + +The LLM sees each function's signature and docstring, using this information to decide when and how to call them. Tool errors are caught and sent back to the LLM, allowing it to recover gracefully. An internal safety limit prevents infinite loops. + +### Custom Tool Definitions + +For custom names or descriptions, use `SamplingTool.from_function()`: + +```python +from fastmcp.server.sampling import SamplingTool + +tool = SamplingTool.from_function( + my_func, + name="custom_name", + description="Custom description" +) + +result = await ctx.sample(messages="...", tools=[tool]) +``` + +### Error Handling + +By default, when a sampling tool raises an exception, the error message (including details) is sent back to the LLM so it can attempt recovery. To prevent sensitive information from leaking to the LLM, use the `mask_error_details` parameter: + +```python +result = await ctx.sample( + messages=question, + tools=[search], + mask_error_details=True, # Generic error messages only +) +``` + +When `mask_error_details=True`, tool errors become generic messages like `"Error executing tool 'search'"` instead of exposing stack traces or internal details. + +To intentionally provide specific error messages to the LLM regardless of masking, raise `ToolError`: + +```python +from fastmcp.exceptions import ToolError + +def search(query: str) -> str: + """Search for information.""" + if not query.strip(): + raise ToolError("Search query cannot be empty") + return f"Results for: {query}" +``` + +`ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle. + +### Concurrent Tool Execution + +By default, tools execute sequentially — one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`: + +```python +result = await ctx.sample( + messages="Research these three topics", + tools=[search, fetch_url], + tool_concurrency=0, # Unlimited parallel execution +) +``` + +The `tool_concurrency` parameter controls how many tools run at once: + +- **`None`** (default): Sequential execution +- **`0`**: Unlimited parallel execution +- **`N > 0`**: Execute at most N tools concurrently + +For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`: + +```python +from fastmcp.server.sampling import SamplingTool + +db_writer = SamplingTool.from_function( + write_to_db, + sequential=True, # Forces all tools in the batch to run sequentially +) + +result = await ctx.sample( + messages="Process this data", + tools=[search, db_writer], + tool_concurrency=0, # Would be parallel, but db_writer forces sequential +) +``` + +<Note> +When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee — if one tool needs ordering, all tools in that batch respect it. +</Note> + +### Client Requirements + +<Note> +Sampling with tools requires the client to advertise the `sampling.tools` capability. FastMCP clients do this automatically. For external clients that don't support tool-enabled sampling, configure a fallback handler with `sampling_handler_behavior="always"`. +</Note> + +## Advanced Control + +<VersionBadge version="2.14.1" /> + +While `sample()` handles the tool execution loop automatically, some scenarios require fine-grained control over each step. The `sample_step()` method makes a single LLM call and returns a `SampleStep` containing the response and updated history. + +Unlike `sample()`, `sample_step()` is stateless—it doesn't remember previous calls. You control the conversation by passing the full message history each time. The returned `step.history` includes all messages up through the current response, making it easy to continue the loop. + +Use `sample_step()` when you need to: + +- Inspect tool calls before they execute +- Implement custom termination conditions +- Add logging, metrics, or checkpointing between steps +- Build custom agentic loops with domain-specific logic + +### Basic Loop + +By default, `sample_step()` executes any tool calls and includes the results in the history. Call it in a loop, passing the updated history each time, until a stop condition is met. + +```python +from fastmcp.types import SamplingMessage +from fastmcp import FastMCP, Context + +mcp = FastMCP() + +def search(query: str) -> str: + return f"Results for: {query}" + +def get_time() -> str: + return "12:00 PM" + +@mcp.tool +async def controlled_agent(question: str, ctx: Context) -> str: + """Agent with manual loop control.""" + messages: list[str | SamplingMessage] = [question] + + while True: + step = await ctx.sample_step( + messages=messages, + tools=[search, get_time], + ) + + if step.is_tool_use: + # Tools already executed (execute_tools=True by default) + for call in step.tool_calls: + print(f"Called tool: {call.name}") + + if not step.is_tool_use: + return step.text or "" + + messages = step.history +``` + +### SampleStep Properties + +Each `SampleStep` provides information about what the LLM returned: + +| Property | Description | +|----------|-------------| +| `step.is_tool_use` | True if the LLM requested tool calls | +| `step.tool_calls` | List of tool calls requested (if any) | +| `step.text` | The text content (if any) | +| `step.history` | All messages exchanged so far | + +The contents of `step.history` depend on `execute_tools`: +- **`execute_tools=True`** (default): Includes tool results, ready for the next iteration +- **`execute_tools=False`**: Includes the assistant's tool request, but you add results yourself + +### Manual Tool Execution + +Set `execute_tools=False` to handle tool execution yourself. When disabled, `step.history` contains the user message and the assistant's response with tool calls—but no tool results. You execute the tools and append the results as a user message. + +```python +from fastmcp.types import SamplingMessage, ToolResultContent, TextContent +from fastmcp import FastMCP, Context + +mcp = FastMCP() + +@mcp.tool +async def research(question: str, ctx: Context) -> str: + """Research with manual tool handling.""" + + def search(query: str) -> str: + return f"Results for: {query}" + + def get_time() -> str: + return "12:00 PM" + + tools = {"search": search, "get_time": get_time} + messages: list[SamplingMessage] = [question] + + while True: + step = await ctx.sample_step( + messages=messages, + tools=list(tools.values()), + execute_tools=False, + ) + + if not step.is_tool_use: + return step.text or "" + + # Execute tools and collect results + tool_results = [] + for call in step.tool_calls: + fn = tools[call.name] + result = fn(**call.input) + tool_results.append( + ToolResultContent( + type="tool_result", + tool_use_id=call.id, + content=[TextContent(type="text", text=result)], + ) + ) + + messages = list(step.history) + messages.append(SamplingMessage(role="user", content=tool_results)) +``` + +To report an error to the LLM, set `is_error=True` on the tool result: + +```python +tool_result = ToolResultContent( + type="tool_result", + tool_use_id=call.id, + content=[TextContent(type="text", text="Permission denied")], + is_error=True, +) +``` + +## Method Reference + +<Card icon="code" title="ctx.sample()"> +<ResponseField name="ctx.sample" type="async method"> + Request text generation from the LLM, running to completion automatically. + + <Expandable title="Parameters"> + <ResponseField name="messages" type="str | list[str | SamplingMessage]"> + The prompt to send. Can be a simple string or a list of messages for multi-turn conversations. + </ResponseField> + + <ResponseField name="system_prompt" type="str | None" default="None"> + Instructions that establish the LLM's role and behavior. + </ResponseField> + + <ResponseField name="temperature" type="float | None" default="None"> + Controls randomness (0.0 = deterministic, 1.0 = creative). + </ResponseField> + + <ResponseField name="max_tokens" type="int | None" default="512"> + Maximum tokens to generate. + </ResponseField> + + <ResponseField name="model_preferences" type="str | list[str] | None" default="None"> + Hints for which model the client should use. + </ResponseField> + + <ResponseField name="tools" type="list[Callable] | None" default="None"> + Functions the LLM can call during sampling. + </ResponseField> + + <ResponseField name="result_type" type="type[T] | None" default="None"> + A type for validated structured output. Supports Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. + </ResponseField> + + <ResponseField name="mask_error_details" type="bool | None" default="None"> + If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM. + </ResponseField> + + <ResponseField name="tool_concurrency" type="int | None" default="None"> + Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless. + </ResponseField> + + </Expandable> + + <Expandable title="Response"> + <ResponseField name="SamplingResult[T]" type="dataclass"> + - `.text`: The raw text response (or JSON for structured output) + - `.result`: The typed result—same as `.text` for plain text, or a validated Pydantic object for structured output + - `.history`: All messages exchanged during sampling + </ResponseField> + </Expandable> +</ResponseField> +</Card> + +<Card icon="code" title="ctx.sample_step()"> +<ResponseField name="ctx.sample_step" type="async method"> + Make a single LLM sampling call. Use this for fine-grained control over the sampling loop. + + <Expandable title="Parameters"> + <ResponseField name="messages" type="str | list[str | SamplingMessage]"> + The prompt or conversation history. + </ResponseField> + + <ResponseField name="system_prompt" type="str | None" default="None"> + Instructions that establish the LLM's role and behavior. + </ResponseField> + + <ResponseField name="temperature" type="float | None" default="None"> + Controls randomness (0.0 = deterministic, 1.0 = creative). + </ResponseField> + + <ResponseField name="max_tokens" type="int | None" default="512"> + Maximum tokens to generate. + </ResponseField> + + <ResponseField name="tools" type="list[Callable] | None" default="None"> + Functions the LLM can call during sampling. + </ResponseField> + + <ResponseField name="tool_choice" type="str | None" default="None"> + Controls tool usage: `"auto"`, `"required"`, or `"none"`. + </ResponseField> + + <ResponseField name="execute_tools" type="bool" default="True"> + If True, execute tool calls and append results to history. If False, return immediately with tool calls available for manual execution. + </ResponseField> + + <ResponseField name="mask_error_details" type="bool | None" default="None"> + If True, mask detailed error messages from tool execution. + </ResponseField> + + <ResponseField name="tool_concurrency" type="int | None" default="None"> + Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. + </ResponseField> + </Expandable> + + <Expandable title="Response"> + <ResponseField name="SampleStep" type="dataclass"> + - `.response`: The raw LLM response + - `.history`: Messages including input, assistant response, and tool results + - `.is_tool_use`: True if the LLM requested tool execution + - `.tool_calls`: List of tool calls (if any) + - `.text`: The text content (if any) + </ResponseField> + </Expandable> +</ResponseField> +</Card> diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index ee778f887..9f6374a5d 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -100,15 +100,15 @@ The `FastMCP` constructor accepts parameters organized into four categories: ide These parameters control how your server presents itself to clients. <Card> -<ParamField body="name" type="str | None" default="None"> - A human-readable name for your server, shown in client applications and logs. If omitted, FastMCP generates a random name +<ParamField body="name" type="str" default="FastMCP"> + A human-readable name for your server, shown in client applications and logs </ParamField> <ParamField body="instructions" type="str | None"> Description of how to interact with this server. Clients surface these instructions to help LLMs understand the server's purpose and available functionality </ParamField> -<ParamField body="version" type="str | int | float | None"> +<ParamField body="version" type="str | None"> Version string for your server. Defaults to the FastMCP library version if not provided </ParamField> @@ -136,29 +136,29 @@ These parameters control how your server presents itself to clients. These parameters control what your server is built from — its components, middleware, providers, and lifecycle. <Card> -<ParamField body="tools" type="Sequence[Tool | Callable] | None"> +<ParamField body="tools" type="list[Tool | Callable] | None"> Tools to register on the server. An alternative to the `@mcp.tool` decorator when you need to add tools programmatically </ParamField> -<ParamField body="auth" type="AuthProvider | None"> +<ParamField body="auth" type="OAuthProvider | TokenVerifier | None"> Authentication provider for securing HTTP-based transports. See [Authentication](/servers/auth/authentication) for configuration </ParamField> -<ParamField body="middleware" type="Sequence[Middleware] | None"> +<ParamField body="middleware" type="list[Middleware] | None"> [Middleware](/servers/middleware) that intercepts and transforms every MCP message flowing through the server — requests, responses, and notifications in both directions. Use for cross-cutting concerns like logging, error handling, and rate limiting </ParamField> -<ParamField body="providers" type="Sequence[Provider] | None"> +<ParamField body="providers" type="list[Provider] | None"> [Providers](/servers/providers/overview) that supply tools, resources, and prompts dynamically. Providers are queried at request time, so they can serve components from databases, APIs, or other external sources </ParamField> -<ParamField body="transforms" type="Sequence[Transform] | None"> +<ParamField body="transforms" type="list[Transform] | None"> <VersionBadge version="3.1.0" /> Server-level [transforms](/servers/transforms/transforms) to apply to all components. Transforms modify how tools, resources, and prompts are presented to clients — for example, [search transforms](/servers/transforms/tool-search) replace large catalogs with on-demand discovery </ParamField> -<ParamField body="lifespan" type="Lifespan | LifespanCallable | None"> +<ParamField body="lifespan" type="Lifespan | AsyncContextManager | None"> Server-level setup and teardown logic that runs when the server starts and stops. See [Lifespans](/servers/lifespan) for composable lifespans </ParamField> </Card> @@ -175,7 +175,7 @@ These parameters tune how the server processes requests and communicates with cl <ParamField body="strict_input_validation" type="bool" default="False"> <VersionBadge version="2.13.0" /> - When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` → `10` for int parameters). When `True`, validates inputs against the exact JSON Schema before calling your function, rejecting type mismatches. See [Validation Modes](/servers/tools#validation-modes) for details + When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` → `10` for int parameters). When `True`, validates inputs against the exact JSON Schema before calling your function, rejecting type mismatches. See [Input Validation Modes](/servers/tools#input-validation-modes) for details </ParamField> <ParamField body="mask_error_details" type="bool | None"> @@ -185,7 +185,7 @@ These parameters tune how the server processes requests and communicates with cl <ParamField body="list_page_size" type="int | None" default="None"> <VersionBadge version="3.0.0" /> - Maximum items per page for list operations (`tools/list`, `resources/list`, etc.). Must be a positive integer when set. When `None`, all results are returned in a single response. See [Pagination](/servers/pagination) for details + Maximum items per page for list operations (`tools/list`, `resources/list`, etc.). When `None`, all results are returned in a single response. See [Pagination](/servers/pagination) for details </ParamField> <ParamField body="tasks" type="bool | None" default="False"> @@ -195,7 +195,7 @@ These parameters tune how the server processes requests and communicates with cl <ParamField body="client_log_level" type="LoggingLevel | None"> <VersionBadge version="3.2.0" /> - Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"` + Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"` </ParamField> <ParamField body="dereference_schemas" type="bool" default="True"> @@ -211,9 +211,19 @@ These parameters tune how the server processes requests and communicates with cl </ParamField> </Card> -### Storage +### Handlers and Storage + +These parameters provide custom handlers for MCP capabilities and persistent storage for session state. <Card> +<ParamField body="sampling_handler" type="SamplingHandler | None"> + Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details +</ParamField> + +<ParamField body="sampling_handler_behavior" type='Literal["always", "fallback"] | None' default="fallback"> + When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests +</ParamField> + <ParamField body="session_state_store" type="AsyncKeyValue | None"> Persistent key-value store for session state that survives across requests. Defaults to an in-memory store. Provide a custom implementation for persistence across server restarts </ParamField> @@ -263,7 +273,7 @@ The filtering logic works as follows: - **Precedence**: Later calls override earlier ones, so call `disable` after `enable` to exclude from an allowlist <Tip> -To hide a component by default, disable it at the server level with `mcp.disable(names={"admin_tool"})`. This is a default rather than a guarantee — a later `enable()` call or a per-session visibility rule can bring the component back. When something must never be reachable, leave it unregistered or guard it with [authentication](/servers/auth/authentication) instead of relying on visibility. +To ensure a component is never exposed, you can set `enabled=False` on the component itself. See the component-specific documentation for details. </Tip> ```python diff --git a/docs/servers/sessions.mdx b/docs/servers/sessions.mdx deleted file mode 100644 index 25f4e8e70..000000000 --- a/docs/servers/sessions.mdx +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: Session State -sidebarTitle: Sessions -description: Persist state across requests on stateless connections. -icon: id-badge ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="4.0.0" /> - -The modern MCP protocol (`2026-07-28`) is stateless. Every request stands alone: the server builds a fresh connection to handle it and discards everything when it returns. There is no session to hang state on, so a tool that wants to remember something between calls — the items in a cart, the thread of a conversation, a running total — has nowhere to keep it. Store it on the connection and it vanishes the moment the request finishes. - -This is a deliberate choice in the protocol. Weighing protocol-level sessions against statelessness, the MCP working group [chose statelessness](https://github.com/modelcontextprotocol/transports-wg/blob/main/docs/sessions-vs-sessionless-decision.md) and moved session semantics up to the application: the server hands the client an identifier, and the client passes it back as an argument on later calls. Their own example is a shopping cart — the server returns a `basket_id`, and the client includes it in each subsequent `add_item` and `checkout` call. - -FastMCP implements that pattern as **session state**, and adds the one thing the bare handle lacks: isolation. State is stored server-side and keyed to the authenticated user, so a handle is inert in anyone else's hands. You pick one of two shapes per tool, depending on whether a user has a single bucket of state or many. - -## Per-user state - -Most tools that remember things want one bucket per user — their preferences, their history, their accumulated context. Declare a `UserSession` parameter and FastMCP injects it, keyed to the authenticated user. It behaves like the request [context](/servers/context): it never appears in the tool's input schema and the caller passes nothing, because the user's identity comes from their validated credentials and selects the right bucket automatically. - -```python -from fastmcp import FastMCP -from fastmcp.server.sessions import UserSession - -mcp = FastMCP("assistant") - - -@mcp.tool -async def remember(fact: str, session: UserSession) -> str: - facts = await session.get("facts", default=[]) - facts.append(fact) - await session.set("facts", facts) - return f"Remembered {len(facts)} facts." -``` - -Because the bucket is chosen from the caller's identity, `UserSession` requires [authentication](/servers/auth/authentication). On an unauthenticated request there is no user to key on, so the tool raises a clear error rather than guessing at a bucket. - -## Distinct sessions - -Sometimes one user needs more than one bucket — separate carts, parallel conversations, independent workflows. Now the caller has to say *which* session it means, so the identifier becomes a tool argument. - -Declare a `SessionId` parameter. Unlike `UserSession`, it appears in the input schema as a string, because the agent is the one that supplies it. FastMCP fills in that argument's description for you — instructing the agent to obtain an id and pass it back — so the tool teaches the protocol on its own, with no prompting on your side. - -An agent obtains an id by calling `create_session`, which comes from a `SessionProvider` — [providers](/servers/providers/overview) are how FastMCP contributes functionality like this. Register one whenever your tools take a `session_id`. Without it there is no way to mint an id, so every id is rejected and the tools cannot resolve a session — a mistake you catch the first time you run them. - -```python -from fastmcp import FastMCP -from fastmcp.server.sessions import SessionId, SessionProvider -from fastmcp.server.dependencies import get_session - -mcp = FastMCP("shop") -mcp.add_provider(SessionProvider()) - - -@mcp.tool -async def add_to_cart(item: str, session_id: SessionId) -> str: - session = await get_session(session_id) - cart = await session.get("cart", default=[]) - cart.append(item) - await session.set("cart", cart) - return f"{len(cart)} items in cart." -``` - -`get_session` resolves and validates the id, returning a [`Session`](#the-session-object). It is a standalone function, not a context method, so it needs no foreground context and works from a [background task](/servers/tasks)'s worker as well as a normal request. - -A session id is real and owned: `create_session` records it under the current user, and only an id created that way resolves. Passing an id that was never created — or one created by a different user — raises rather than quietly opening a fresh bucket, so a typo or a stolen id fails loudly instead of misrouting state. - -When your application already mints its own identifiers — conversation ids, workflow ids — take them as ordinary string arguments rather than `SessionId`, and skip the provider entirely; `SessionId` is specifically the create-then-pass contract backed by `create_session`. - -## The session object - -Both patterns give a tool a `Session`: an async view over one bucket of stored state. Read a value with `await session.get(key, default=None)`, write one with `await session.set(key, value)`, and remove one with `await session.delete(key)`. Values are stored as JSON, so anything JSON-serializable round-trips. - -The session's own identifier is available as `session.id` — the id for a session resolved from a `session_id` argument, and `None` for an injected `UserSession`, which has no distinct id because its bucket is the authenticated user. - -`await session.clear()` empties the session's state while keeping the session itself valid — the id still resolves, the bucket is just empty. To retire a session entirely, an agent calls `end_session`, which deletes it so the id no longer resolves at all. - -## Isolation - -Every session is keyed by two things, in this order: the authenticated user, then the session id. The order is the whole security model. The user is the wall; the id only organizes sessions *within* that wall. - -On an authenticated request the user comes from the validated token, which the caller cannot forge. Two different users can pass the very same session id and never reach each other's data, because each id is namespaced under its user's identity. This makes a session id safe to expose — it travels through the agent's context and your logs, and on its own it grants nothing. Guessing another user's id leads nowhere: it was created under *their* namespace, so in the guesser's namespace it simply does not exist and the call is rejected. - -Without authentication there is no user to key on, and the guarantee changes. - -**An unauthenticated session is a bearer handle: whoever holds the id can read and write it.** Ids from `create_session` are unguessable, which keeps a caller from stumbling onto another session, but that is guess-resistance, not isolation — a leaked id is a leaked session. Treat unauthenticated sessions as single-tenant: sound for a personal server with one trusted client, never a boundary between tenants. Multi-tenant isolation requires authentication. - -## Storage and lifetime - -Session state lives in the server's [storage backend](/servers/storage-backends) — in-memory by default, or Redis or another shared store when a fleet of servers must see the same sessions. Because the store owns retention, it owns expiry: FastMCP writes session state without a TTL of its own, so the store you configure is the single place session data lives and expires. To give every session a default lifetime, wrap the store so writes without an explicit TTL get one — for example, the `key-value` library's TTL-clamp wrapper takes a `missing_ttl`: - -```python -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.ttl_clamp import TTLClampWrapper - -store = RedisStore(url="redis://localhost:6379") -store = TTLClampWrapper(store, min_ttl=0, max_ttl=86400, missing_ttl=3600) - -mcp = FastMCP("shop", session_state_store=store) -``` - -Now a session expires an hour after its last write, and `end_session` still removes one immediately. - -## Relationship to request state - -The request [context](/servers/context) also carries state, through `ctx.set_state` and `ctx.get_state`, and the two solve different problems. Context state is scoped to a single request — the right place for a value that a middleware sets and a handler reads within the same call. Session state is what persists *across* requests. When you need a value to survive from one tool call to the next, reach for `UserSession` or `SessionId`; when it only needs to live for the current request, keep it on the context. diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx index 32e6530eb..d13ce176d 100644 --- a/docs/servers/storage-backends.mdx +++ b/docs/servers/storage-backends.mdx @@ -156,7 +156,9 @@ The [OAuth Proxy](/servers/auth/oauth-proxy) and OAuth auth providers use storag **Development (default behavior):** -By default, FastMCP automatically manages keys and storage the same way on every platform: the signing key is deterministically derived from your client secret, and storage defaults to an encrypted disk store in your platform's data directory (derived from `platformdirs`). Suitable **only** for development and local testing. +By default, FastMCP automatically manages keys and storage based on your platform: +- **Mac/Windows**: Keys are auto-managed via system keyring, storage defaults to disk. Suitable **only** for development and local testing. +- **Linux**: Keys are ephemeral, storage defaults to memory. No configuration needed: @@ -199,7 +201,7 @@ Both parameters are required for production. **Wrap your storage in `FernetEncry ### Response Caching Middleware -The [Response Caching Middleware](/servers/middleware#caching) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter: +The [Response Caching Middleware](/servers/middleware#caching-middleware) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter: ```python from pathlib import Path @@ -289,6 +291,6 @@ This allows clients to reconnect without re-authenticating after restarts. ## More Resources - [py-key-value-aio GitHub](https://github.com/strawgate/py-key-value) - Full library documentation -- [Response Caching Middleware](/servers/middleware#caching) - Using storage for caching +- [Response Caching Middleware](/servers/middleware#caching-middleware) - Using storage for caching - [OAuth Token Security](/deployment/http#oauth-token-security) - Production OAuth configuration - [HTTP Deployment](/deployment/http) - Complete deployment guide diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index 7b374473d..d4aae9f54 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -1,59 +1,58 @@ --- title: Background Tasks sidebarTitle: Background Tasks -description: Run long-running tools asynchronously with progress tracking +description: Run long-running operations asynchronously with progress tracking icon: clock tag: "NEW" --- import { VersionBadge } from "/snippets/version-badge.mdx" -<VersionBadge version="4.0.0" /> +<VersionBadge version="2.14.0" /> <Tip> -Background tasks require the `fastmcp-tasks` package. See [enabling background tasks](#enabling-background-tasks) below. +Background tasks require the `tasks` optional extra. See [installation instructions](#enabling-background-tasks) below. </Tip> -FastMCP implements the MCP background tasks extension ([`io.modelcontextprotocol/tasks`](https://modelcontextprotocol.io/extensions/tasks/overview), SEP-2663), giving your servers a production-ready distributed task scheduler with one extension registration and a decorator change. +FastMCP implements the MCP background task protocol ([SEP-1686](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)), giving your servers a production-ready distributed task scheduler with a single decorator change. <Tip> **What is Docket?** FastMCP's task system is powered by [Docket](https://github.com/chrisguidry/docket), originally built by [Prefect](https://prefect.io) to power [Prefect Cloud](https://www.prefect.io/prefect/cloud)'s managed task scheduling and execution service, where it processes millions of concurrent tasks every day. Docket is now open-sourced for the community. </Tip> + ## What Are MCP Background Tasks? -In MCP, a tool call is blocking by default. When a client calls a tool, it sends a request and waits for the response. For operations that take seconds or minutes, this creates a poor user experience. +In MCP, all component interactions are blocking by default. When a client calls a tool, reads a resource, or fetches a prompt, it sends a request and waits for the response. For operations that take seconds or minutes, this creates a poor user experience. -Background tasks solve this by letting a server tell a supporting client: -1. **Start** the tool and return a task ID immediately -2. **Poll** for status as the tool runs -3. **Retrieve** the result when ready — or answer a question the tool asks mid-run +The MCP background task protocol solves this by letting clients: +1. **Start** an operation and receive a task ID immediately +2. **Track** progress as the operation runs +3. **Retrieve** the result when ready -FastMCP handles all of this for you. Add `task=True` to a tool decorator and register the tasks extension, and your function gains background execution with progress reporting, distributed processing, and horizontal scaling. +FastMCP handles all of this for you. Add `task=True` to your decorator, and your function gains full background execution with progress reporting, distributed processing, and horizontal scaling. ### MCP Background Tasks vs Python Concurrency You can always use Python's concurrency primitives (asyncio, threads, multiprocessing) or external task queues in your FastMCP servers. FastMCP is just Python—run code however you like. -MCP background tasks are different: they're **protocol-native**. This means MCP clients that support the tasks extension can start a call, poll it, and retrieve its result through the standard MCP interface. The coordination happens at the protocol level, not inside your application code. +MCP background tasks are different: they're **protocol-native**. This means MCP clients that support the task protocol can start operations, receive progress updates, and retrieve results through the standard MCP interface. The coordination happens at the protocol level, not inside your application code. ## Enabling Background Tasks -Background tasks require the `fastmcp-tasks` package: +<VersionBadge version="3.0.0" /> Background tasks require the `tasks` extra: ```bash pip install "fastmcp[tasks]" ``` -Register `TasksExtension` on your server, then add `task=True` to a tool decorator. `task=True` marks the tool as *capable* of background execution; the extension is what actually runs it — a `task=True` tool on a server with no tasks extension registered raises at server startup. +Add `task=True` to any tool, resource, resource template, or prompt decorator. This marks the component as capable of background execution. -```python {5,8} +```python {6} import asyncio from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension mcp = FastMCP("MyServer") -mcp.add_extension(TasksExtension()) @mcp.tool(task=True) async def slow_computation(duration: int) -> str: @@ -63,38 +62,34 @@ async def slow_computation(duration: int) -> str: return f"Completed in {duration} seconds" ``` -Whether a given call actually runs as a task depends on the client: it opts in per request, and the *server* decides based on the tool's execution mode (below). When it does run as a task, the call returns immediately with a task ID; the work executes in a background worker, and the client polls for the result. A [FastMCP client](/clients/tasks) does all of this transparently — `client.call_tool(...)` looks the same either way. - -Background tasks are a modern-protocol feature: the tasks capability is negotiated over `2026-07-28` connections, so a client pinned to `mode="legacy"` never triggers one — the tool always runs synchronously for it. +When a client requests background execution, the call returns immediately with a task ID. The work executes in a background worker, and the client can poll for status or wait for the result. <Warning> -Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time. Only tools can be task-enabled; resources, resource templates, and prompts do not carry `task=`. +Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time. </Warning> ## Execution Modes -For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The tasks extension defines three execution modes: +For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The MCP task protocol defines three execution modes: -| Mode | Client calls without the tasks capability | Client calls with the tasks capability | +| Mode | Client calls without task | Client calls with task | |------|--------------------------|------------------------| -| `"forbidden"` | Executes synchronously | Executes synchronously (never tasked) | -| `"optional"` | Executes synchronously | Executes as a background task | -| `"required"` | Error: task required | Executes as a background task | +| `"forbidden"` | Executes synchronously | Error: task not supported | +| `"optional"` | Executes synchronously | Executes as background task | +| `"required"` | Error: task required | Executes as background task | ```python from fastmcp import FastMCP -from fastmcp.utilities.tasks import TaskConfig -from fastmcp_tasks import TasksExtension +from fastmcp.server.tasks import TaskConfig mcp = FastMCP("MyServer") -mcp.add_extension(TasksExtension()) # Supports both sync and background execution (default when task=True) @mcp.tool(task=TaskConfig(mode="optional")) async def flexible_task() -> str: return "Works either way" -# Requires background execution - errors if the client didn't opt in +# Requires background execution - errors if client doesn't request task @mcp.tool(task=TaskConfig(mode="required")) async def must_be_background() -> str: return "Only runs as a background task" @@ -109,20 +104,18 @@ The boolean shortcuts map to these modes: - `task=True` → `TaskConfig(mode="optional")` - `task=False` → `TaskConfig(mode="forbidden")` -When a `mode="required"` tool is called by a client that didn't opt in, FastMCP returns a "missing required capability" error rather than running it synchronously. - ### Poll Interval -When a client polls for task status, the server can suggest how frequently to check back: +<VersionBadge version="2.15.0" /> + +When clients poll for task status, the server tells them how frequently to check back. By default, FastMCP suggests a 5-second interval, but you can customize this per component: ```python from datetime import timedelta from fastmcp import FastMCP -from fastmcp.utilities.tasks import TaskConfig -from fastmcp_tasks import TasksExtension +from fastmcp.server.tasks import TaskConfig mcp = FastMCP("MyServer") -mcp.add_extension(TasksExtension()) # Poll every 2 seconds for a fast-completing task @mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=2))) @@ -135,34 +128,31 @@ async def slow_task() -> str: return "Eventually done" ``` -Shorter intervals give clients faster feedback but increase server load. The interval is a ceiling, not an exact cadence — the FastMCP client starts polling quickly and backs off toward it, so a fast task is still observed as done almost immediately. +Shorter intervals give clients faster feedback but increase server load. Longer intervals reduce load but delay status updates. ### Server-Wide Default -To enable background task support for all tools by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`. +To enable background task support for all components by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`. ```python mcp = FastMCP("MyServer", tasks=True) ``` <Warning> -If your server defines any synchronous tools, you will need to explicitly set `task=False` on their decorators to avoid an error. +If your server defines any synchronous tools, resources, or prompts, you will need to explicitly set `task=False` on their decorators to avoid an error. </Warning> +### Graceful Degradation + +When a client requests background execution but the component has `mode="forbidden"`, FastMCP executes synchronously and returns the result inline. This follows the SEP-1686 specification for graceful degradation—clients can always request background execution without worrying about server capabilities. + +Conversely, when a component has `mode="required"` but the client doesn't request background execution, FastMCP returns an error indicating that task execution is required. + ### Configuration -`TasksExtension` takes the backend configuration directly, with `FASTMCP_DOCKET_*` environment variables as defaults — so `TasksExtension()` works out of the box against an env-configured deployment: - -```python -mcp.add_extension(TasksExtension(url="redis://localhost:6379/0", concurrency=20)) -``` - | Environment Variable | Default | Description | |---------------------|---------|-------------| | `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) | -| `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. | -| `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. | -| `FASTMCP_TASKS_ENCRYPTION_KEY` | (unset) | Encrypts [task context snapshots at rest](#credentials-at-rest). Every server and worker sharing a queue must set the same key. | ## Backends @@ -183,54 +173,28 @@ The in-memory backend (`memory://`) requires zero configuration and works out of ### Redis Backend -For production deployments, use Redis (or Valkey) as your backend: - -```python -mcp.add_extension(TasksExtension(url="redis://localhost:6379/0")) -``` +For production deployments, use Redis (or Valkey) as your backend by setting `FASTMCP_DOCKET_URL=redis://localhost:6379`. **Advantages:** - **Persistent**: Tasks survive server restarts - **Fast**: Single-digit millisecond task pickup latency - **Scalable**: Add workers to distribute load across processes or machines -### Credentials at Rest - -A background task runs long after the request that submitted it has ended, but it still needs to know who asked for the work. FastMCP captures that identity at submission time in a **task context snapshot**: the caller's access token and every inbound HTTP header, including `Authorization`. The worker restores the snapshot before the tool body runs, so `get_access_token()` and `get_http_headers()` return the submitting caller. - -That snapshot lives in the backend for the task's TTL. With `memory://` it never leaves the process. With Redis or Valkey it is a stored value, and by default it is stored as plaintext JSON. A `rediss://` URL encrypts the connection, not the data the backend holds. Anyone who can read the backend can read the tokens. - -Set `FASTMCP_TASKS_ENCRYPTION_KEY` to encrypt the snapshot before it is written: - -```bash -export FASTMCP_TASKS_ENCRYPTION_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))") -``` - -<Warning> -Every server and worker on the same queue must set the same key. The process that restores a snapshot is rarely the one that captured it, and a worker with the wrong key cannot recover the caller. -</Warning> - -With a key configured, restore **fails closed**: a worker that cannot decrypt a snapshot fails the task instead of running the tool with no identity. This matters for a tool whose behavior depends on the caller: running it as an anonymous user is worse than not running it. The failure is reported to the client as a task error, and the server log names the key mismatch. - -Two consequences of failing closed are worth planning for. Tasks submitted before the key was set fail when a worker with the key picks them up, so drain the queue before you roll a key out. Rotating a key does the same to tasks in flight under the old one. - -The key protects the snapshot only. Tool arguments and any answers a task gathers through [mid-task input](#gathering-input-mid-task) are still stored as plaintext, so treat the backend as sensitive regardless. - ## Workers -Every FastMCP server with task-enabled tools automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute. +Every FastMCP server with task-enabled components automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute. -To scale horizontally, add more workers: +To scale horizontally, add more workers using the CLI: ```bash -python -m fastmcp_tasks.worker_cli worker server.py +fastmcp tasks worker server.py ``` Each additional worker pulls tasks from the same queue, distributing load across processes. Configure worker concurrency via environment: ```bash export FASTMCP_DOCKET_CONCURRENCY=20 -python -m fastmcp_tasks.worker_cli worker server.py +fastmcp tasks worker server.py ``` <Note> @@ -238,52 +202,7 @@ Additional workers only work with Redis/Valkey backends. The in-memory backend i </Note> <Warning> -Task-enabled tools must be defined at server startup to be registered with all workers. Tools added dynamically after the server starts will not be available for background execution. -</Warning> - -## Gathering Input Mid-Task - -A tool can ask the client a question partway through — the same [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) used for multi-round-trip input on foreground calls: instead of awaiting a response, the tool *returns* one, and FastMCP re-runs it once the client answers. - -```python -from fastmcp import Context, FastMCP -from fastmcp_tasks import TasksExtension -from mcp.types import ( - ElicitRequest, - ElicitRequestFormParams, - ElicitResult, - InputRequiredResult, -) - -mcp = FastMCP("MyServer") -mcp.add_extension(TasksExtension()) - -@mcp.tool(task=True) -async def plan_dinner(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - # First leg: ask a question and end here. - request = ElicitRequest( - params=ElicitRequestFormParams( - message="What are you in the mood for?", - requested_schema={"type": "object", "properties": {"cuisine": {"type": "string"}}}, - ) - ) - return InputRequiredResult( - result_type="input_required", - input_requests={"prefs": request}, - ) - - # Re-entered leg: the client's answer is on ctx.input_responses. - answer = responses["prefs"] - assert isinstance(answer, ElicitResult) - return f"Tonight: {answer.content['cuisine']}!" -``` - -Run as a task, this "ends" the tool's first leg entirely rather than blocking a worker on the client's answer: the task reports `input_required`, the client answers, and FastMCP re-invokes the tool with the answer attached. No worker ever sits idle waiting on a round-trip — the same tool works identically whether it's called synchronously or as a background task, and a [FastMCP client](/clients/tasks) answers the question automatically through its elicitation handler. - -<Warning> -Imperative `await ctx.elicit(...)` is not supported inside a background task — it would require blocking a worker for the length of a client round-trip. Use the guard pattern (return `InputRequiredResult`) instead; calling `ctx.elicit()` from a task-enabled tool raises with guidance toward the guard pattern. +Task-enabled components must be defined at server startup to be registered with all workers. Components added dynamically after the server starts will not be available for background execution. </Warning> ## Progress Reporting @@ -322,8 +241,7 @@ FastMCP exposes Docket's full dependency injection system within your task-enabl ```python from docket import Docket, Worker from fastmcp import FastMCP -from fastmcp.dependencies import Progress -from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker +from fastmcp.dependencies import Progress, CurrentDocket, CurrentWorker mcp = FastMCP("MyServer") @@ -342,4 +260,4 @@ async def my_task( return "Done" ``` -With `CurrentDocket()`, you can schedule additional background tasks, chain work together, and coordinate complex workflows. See the [Docket documentation](https://docket.lol/) for the complete API, including retry policies, timeouts, and custom dependencies. +With `CurrentDocket()`, you can schedule additional background tasks, chain work together, and coordinate complex workflows. See the [Docket documentation](https://chrisguidry.github.io/docket/) for the complete API, including retry policies, timeouts, and custom dependencies. diff --git a/docs/servers/telemetry.mdx b/docs/servers/telemetry.mdx index 9573f07bd..77fb0f1de 100644 --- a/docs/servers/telemetry.mdx +++ b/docs/servers/telemetry.mdx @@ -6,8 +6,6 @@ icon: chart-line tag: NEW --- -import { VersionBadge } from "/snippets/version-badge.mdx" - FastMCP includes native OpenTelemetry instrumentation for observability. Traces are automatically generated for tool, prompt, resource, resource template, and task management operations, providing visibility into server behavior, request handling, and provider delegation chains. ## How It Works @@ -21,21 +19,9 @@ FastMCP uses the OpenTelemetry API for instrumentation. This means: Because FastMCP only depends on the OpenTelemetry API, span creation is a no-op until you configure an SDK and exporter — so being on by default costs nothing until you opt into collection. -### Telemetry Modes +### Turning Telemetry Off -<VersionBadge version="4.0.0" /> - -`FASTMCP_TELEMETRY_MODE` (or `fastmcp.settings.telemetry_mode`) controls how much of the instrumentation is active: - -| Mode | FastMCP spans | Trace context | -|---|---|---| -| `native` (default) | Emitted | Propagated | -| `propagation_only` | Suppressed | Propagated | -| `off` | Suppressed | Untouched | - -Use `off` to disable FastMCP's instrumentation entirely. No spans are created even if an SDK is configured, and FastMCP leaves the surrounding OpenTelemetry context exactly as it found it. - -Use `propagation_only` when another instrumentation layer already owns the MCP span hierarchy — see [Interoperability](#interoperability) below. +To disable FastMCP's instrumentation entirely, set `FASTMCP_ENABLE_TELEMETRY=false` (or `fastmcp.settings.enable_telemetry = False`). When disabled, FastMCP creates no spans even if an SDK is configured. ## Enabling Telemetry @@ -83,7 +69,7 @@ The server creates spans for each operation using [MCP semantic conventions](htt | `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) | | `resources/read` | Resource read (URI in `mcp.resource.uri` attribute, not span name) | | `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) | -| `tasks/{operation}` | Task management (`tasks/get`, `tasks/update`, or `tasks/cancel`) | +| `tasks/{operation}` | Task management (`tasks/get`, `tasks/result`, `tasks/list`, or `tasks/cancel`) | For mounted servers, an additional `delegate {name}` span shows the delegation to the child server. @@ -114,12 +100,12 @@ tools/call remote_search (CLIENT) Background task traces have two parts: -- Task submission and management requests use normal client-to-server context propagation. `tasks/get`, `tasks/update`, and `tasks/cancel` server spans are descendants of the corresponding FastMCP client spans. +- Task submission and management requests use normal client-to-server context propagation. `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel` server spans are descendants of the corresponding FastMCP client spans. - Deferred execution runs in a Docket worker. Docket records its `CONSUMER` span as a new trace root with a span link to the submission context, rather than making it a child of the submission span. Custom spans created inside the task are children of that worker span. Span links preserve the causal relationship without forcing worker sampling to inherit the submit trace's sampling decision. Some tracing backends do not display links prominently, so the worker trace may look disconnected even though the link is present. -Frequent status polling can produce more detail than you need. You can drop those client and server spans with a sampler that checks the span name before delegating to `ParentBased`: +Frequent status and list polling can produce more detail than you need. You can drop those client and server spans with a sampler that checks the span name before delegating to `ParentBased`: ```python from opentelemetry import trace @@ -138,7 +124,7 @@ class DropTaskPolls(Sampler): self._delegate = ParentBased(ALWAYS_ON) def should_sample(self, parent_context, trace_id, name, *args, **kwargs): - if name in {"tasks/get"}: + if name in {"tasks/get", "tasks/list"}: return SamplingResult(Decision.DROP) return self._delegate.should_sample( parent_context, @@ -158,37 +144,6 @@ trace.set_tracer_provider(provider) The name check must happen before `ParentBased` delegates. If the name-based sampler is nested inside `ParentBased`, it is not consulted for child spans whose parent was already sampled. -## Interoperability - -<VersionBadge version="4.0.0" /> - -FastMCP assumes it owns the MCP span hierarchy. When something else already owns it — an MCP-aware OpenTelemetry instrumentation library, or a service mesh that understands the protocol — FastMCP's spans duplicate what that layer already emits, and the same request shows up twice in your traces. - -Setting `propagation_only` resolves the duplication in FastMCP's favor of the other layer: - -```bash -export FASTMCP_TELEMETRY_MODE=propagation_only -``` - -The distinction from `off` matters here. Both emit no FastMCP spans, but `off` is fully transparent, while `propagation_only` still extracts the trace context arriving in `_meta` and attaches it for the duration of the request. Spans created downstream — by your tool handlers, or by the instrumentation layer that owns the hierarchy — are parented to the calling trace rather than starting a new one. Outbound requests still carry `traceparent` and `tracestate` in `_meta`. - -### Suppressing spans for a single block - -Library authors embedding FastMCP inside their own instrumented stack often want to own the hierarchy for one specific operation rather than process-wide. `suppress_fastmcp_telemetry()` applies `propagation_only` semantics to a block: - -```python -from fastmcp import Client -from fastmcp.telemetry import suppress_fastmcp_telemetry - -async def search(client: Client, query: str): - with suppress_fastmcp_telemetry(): - return await client.call_tool("search", {"query": query}) -``` - -This is narrower than OpenTelemetry's global instrumentation suppression: only FastMCP's spans are skipped, so nested instrumentation for HTTP clients, databases, and everything else keeps emitting normally. - -The context manager has no effect when `telemetry_mode` is already `off`. A request to skip FastMCP's spans cannot re-enable the context propagation that `off` deliberately omits. - ## Programmatic Configuration For more control, configure the SDK in your Python code before importing FastMCP: @@ -283,7 +238,7 @@ Custom spans are most useful around work that is expensive or hard to debug: - External calls such as databases, vector stores, HTTP APIs, or queue operations - Multi-step tool logic where one stage dominates latency - Prompt or resource generation that fans out to other systems -- LLM calls a tool makes to a model provider +- Sampling calls made from inside a tool via `ctx.sample(...)` Avoid wrapping every small helper function or simple in-memory transformation. That usually adds noise without making traces easier to interpret. @@ -331,9 +286,9 @@ async def docs_resource(slug: str) -> str: return await load_doc(slug) ``` -### LLM calls inside tools +### Sampling calls inside tools -A tool that [calls an LLM directly](/servers/sampling) should keep the model work nested under the tool span, so traces show application logic and model latency together. +If your tool uses `ctx.sample(...)`, keep the LLM work nested under the tool span so traces show both application logic and model latency together. For providers with their own OTEL integrations, prefer enabling that instrumentation rather than manually creating a span around every model call. For example, if you use Google GenAI, `logfire.instrument_google_genai()` will emit child spans with token and request metadata under the active FastMCP tool span. @@ -395,7 +350,7 @@ All custom attributes use the `fastmcp.` prefix for features unique to FastMCP: |-----------|-------------| | `fastmcp.server.name` | Server name | | `fastmcp.component.type` | `tool`, `resource`, `prompt`, or `resource_template` | -| `fastmcp.component.key` | Full component key, including type and version delimiter (e.g., `tool:greet@` or `tool:greet@v2`) | +| `fastmcp.component.key` | Full component identifier (e.g., `tool:greet`) | | `fastmcp.provider.type` | Provider class (`LocalProvider`, `FastMCPProvider`, `ProxyProvider`) | Provider-specific attributes for delegation context: diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 4a9c4918d..bc5b7d228 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -73,14 +73,14 @@ def search_products_implementation(query: str, category: str | None = None) -> l Provides the description exposed via MCP. If set, the function's docstring is ignored for the tool description, though docstring-derived parameter descriptions still apply (see [Docstring Descriptions](#docstring-descriptions)). </ParamField> -<ParamField body="title" type="str | None"> - A human-readable display title for the tool. If omitted, FastMCP falls back to `annotations.title` when present, then to a title derived from the tool's name (e.g. `find_products` becomes "Find Products") — some MCP clients drop tools that have no title at all. -</ParamField> - <ParamField body="tags" type="set[str] | None"> A set of strings used to categorize the tool. These can be used by the server and, in some cases, by clients to filter or group available tools. </ParamField> +<ParamField body="enabled" type="bool" default="True"> + <Warning>Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead.</Warning> + A boolean to enable or disable the tool. See [Component Visibility](#component-visibility) for the recommended approach. +</ParamField> <ParamField body="icons" type="list[Icon] | None"> <VersionBadge version="2.13.0" /> @@ -431,7 +431,7 @@ def get_user_details(user_id: str = Depends(get_user_id)) -> str: return f"Details for {user_id}" ``` -See [Custom Dependencies](/servers/dependency-injection#custom-dependencies) for more details on dependency injection. +See [Custom Dependencies](/servers/context#custom-dependencies) for more details on dependency injection. ## Return Values @@ -722,8 +722,8 @@ Schema generation works for most common types including basic types, collections For complete control over tool responses, return a `ToolResult` object. This gives you explicit control over all aspects of the tool's output: traditional content, structured data, and metadata. ```python -from fastmcp.tools import ToolResult -from mcp.types import TextContent +from fastmcp.tools.tool import ToolResult +from fastmcp.types import TextContent @mcp.tool def advanced_tool() -> ToolResult: @@ -788,7 +788,7 @@ When you need custom serialization (like YAML, Markdown tables, or specialized f ```python import yaml from fastmcp import FastMCP -from fastmcp.tools import ToolResult +from fastmcp.tools.tool import ToolResult mcp = FastMCP("MyServer") @@ -918,7 +918,7 @@ def public_action() -> str: return "Done" # Disable specific tools by key -mcp.disable(names={"admin_action"}) +mcp.disable(keys={"tool:admin_action"}) # Disable tools by tag mcp.disable(tags={"admin"}) @@ -944,7 +944,7 @@ Annotations serve several purposes in client applications: You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool` decorator. FastMCP accepts either a plain dict or `ToolAnnotations`; the examples below use `ToolAnnotations` for consistency and stronger editor/type support. ```python -from mcp.types import ToolAnnotations +from fastmcp.types import ToolAnnotations @mcp.tool( annotations=ToolAnnotations( @@ -983,7 +983,7 @@ Mark a tool as read-only when it retrieves data, performs calculations, or check ```python from fastmcp import FastMCP -from mcp.types import ToolAnnotations +from fastmcp.types import ToolAnnotations mcp = FastMCP("Data Server") @@ -1034,8 +1034,8 @@ def example_tool() -> str: # These operations trigger notifications: mcp.add_tool(example_tool) # Sends tools/list_changed notification -mcp.disable(names={"example_tool"}) # Sends tools/list_changed notification -mcp.enable(names={"example_tool"}) # Sends tools/list_changed notification +mcp.disable(keys={"tool:example_tool"}) # Sends tools/list_changed notification +mcp.enable(keys={"tool:example_tool"}) # Sends tools/list_changed notification mcp.local_provider.remove_tool("example_tool") # Sends tools/list_changed notification ``` @@ -1056,14 +1056,22 @@ mcp = FastMCP(name="ContextDemo") async def process_data(data_uri: str, ctx: Context) -> dict: """Process data from a resource with progress reporting.""" await ctx.info(f"Processing data from {data_uri}") - - result = await ctx.read_resource(data_uri) - data = result.contents[0].content if result.contents else "" + + # Read a resource + resource = await ctx.read_resource(data_uri) + data = resource[0].content if resource else "" + + # Report progress await ctx.report_progress(progress=50, total=100) - - summary = str(data)[:200] + + # Example request to the client's LLM for help + summary = await ctx.sample(f"Summarize this in 10 words: {data[:200]}") + await ctx.report_progress(progress=100, total=100) - return {"length": len(data), "summary": summary} + return { + "length": len(data), + "summary": summary.text + } ``` The Context object provides access to: @@ -1071,6 +1079,7 @@ The Context object provides access to: - **Logging**: `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()` - **Progress Reporting**: `ctx.report_progress(progress, total)` - **Resource Access**: `ctx.read_resource(uri)` +- **LLM Sampling**: `ctx.sample(...)` - **Request Information**: `ctx.request_id`, `ctx.client_id` For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). @@ -1081,22 +1090,22 @@ For full documentation on the Context object and all its capabilities, see the [ <VersionBadge version="2.1.0" /> -You can control how the FastMCP server behaves if you register the same component twice. Identity is the component's type, name, and version together, so a tool and a prompt may share a name, and two versions of one tool coexist. Only an exact repeat of all three counts as a duplicate. The `on_duplicate` argument sets that policy once for every component type. +You can control how the FastMCP server behaves if you try to register multiple tools with the same name. This is configured using the `on_duplicate_tools` argument when creating the `FastMCP` instance. ```python from fastmcp import FastMCP mcp = FastMCP( name="StrictServer", - # Configure behavior for exact component duplicates - on_duplicate="error" + # Configure behavior for duplicate tool names + on_duplicate_tools="error" ) @mcp.tool def my_tool(): return "Version 1" # This will now raise a ValueError because 'my_tool' already exists -# and on_duplicate is set to "error". +# and on_duplicate_tools is set to "error". # @mcp.tool # def my_tool(): return "Version 2" ``` diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx index 0e7e4f50d..c7ef55bf0 100644 --- a/docs/servers/transforms/code-mode.mdx +++ b/docs/servers/transforms/code-mode.mdx @@ -140,7 +140,7 @@ You can cap result count with `default_limit`. The LLM can also override the lim Search(default_limit=5) # return at most 5 results per search ``` -If your tools use [tags](/servers/visibility#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching. +If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching. ### GetSchemas @@ -148,7 +148,7 @@ If your tools use [tags](/servers/visibility#tags), Search also accepts a `tags` ### GetTags -`GetTags` lets the LLM browse tools by category using [tag](/servers/visibility#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag: +`GetTags` lets the LLM browse tools by category using [tag](/servers/tools#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag: ``` - math (3 tools) @@ -187,7 +187,7 @@ from fastmcp.experimental.transforms.code_mode import CodeMode mcp = FastMCP("Server", transforms=[CodeMode()]) ``` -If your tools use [tags](/servers/visibility#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure: +If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure: ```python from fastmcp import FastMCP @@ -250,7 +250,7 @@ Here's a minimal example: from fastmcp.experimental.transforms.code_mode import CodeMode from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas from fastmcp.server.context import Context -from fastmcp.tools import Tool +from fastmcp.tools.tool import Tool def list_all_tools(get_catalog: GetToolCatalog) -> Tool: async def list_tools(ctx: Context) -> str: diff --git a/docs/servers/transforms/tool-search.mdx b/docs/servers/transforms/tool-search.mdx index c3f44a3cf..204004f5c 100644 --- a/docs/servers/transforms/tool-search.mdx +++ b/docs/servers/transforms/tool-search.mdx @@ -153,8 +153,6 @@ Tools discovered through search can also be called directly via `client.call_too Search results respect the full authorization pipeline. Tools filtered by middleware, visibility transforms, or component-level auth checks won't appear in search results. -App-only tools are excluded too. A [MCP app](/apps/overview) can declare backend tools that only its UI may call, and normally the host keeps those from the model. A search result is tool output rather than an advertised listing, so no host filtering applies to it — the exclusion happens here instead. The `call_tool` proxy enforces the same boundary, since it executes a name the model supplies. - The search tool queries `list_tools()` through the complete pipeline at search time, so the same filtering that controls what a client sees in the listing also controls what they can discover through search. ```python diff --git a/docs/servers/transforms/transforms.mdx b/docs/servers/transforms/transforms.mdx index fc8f0ceda..4347b2f18 100644 --- a/docs/servers/transforms/transforms.mdx +++ b/docs/servers/transforms/transforms.mdx @@ -118,7 +118,7 @@ Create custom transforms by subclassing `Transform` and overriding the methods y ```python from collections.abc import Sequence from fastmcp.server.transforms import Transform, GetToolNext -from fastmcp.tools import Tool +from fastmcp.tools.tool import Tool class TagFilter(Transform): """Filter tools to only those with specific tags.""" diff --git a/docs/servers/versioning.mdx b/docs/servers/versioning.mdx index 0dfa0bc69..4c44a73bd 100644 --- a/docs/servers/versioning.mdx +++ b/docs/servers/versioning.mdx @@ -114,7 +114,7 @@ def process(data: str, mode: str = "default") -> str: return data.upper() ``` -Both versions are registered. Server-side `list_tools()` returns every registered version, while MCP client list operations deduplicate by identifier and expose the highest version with metadata describing the available versions. When a client invokes `process` without specifying a version, version 2.0 executes. The same pattern applies to resources and prompts. +Both versions are registered. When a client lists tools, they see only `process` with version 2.0 (the highest). When they invoke `process`, version 2.0 executes. The same pattern applies to resources and prompts. ### Versioned vs Unversioned Components @@ -189,7 +189,7 @@ By default, clients receive and invoke the highest version of each component. Wh ### FastMCP Client -The FastMCP client's `call_tool`, `read_resource`, and `get_prompt` methods accept an optional `version` parameter. When specified, the server executes that exact version instead of the highest. +The FastMCP client's `call_tool` and `get_prompt` methods accept an optional `version` parameter. When specified, the server executes that exact version instead of the highest. ```python from fastmcp import Client @@ -201,9 +201,6 @@ async with Client(server) as client: # Call a specific version result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0") - # Read a specific resource version - data = await client.read_resource("config://app", version="1.0") - # Get a specific prompt version prompt = await client.get_prompt("summarize", {"text": "..."}, version="1.0") ``` @@ -212,16 +209,13 @@ If the requested version doesn't exist, the server raises a `NotFoundError`. Thi ### MCP Protocol -For generic MCP clients that don't have built-in version support, pass the version through the request params `_meta` field. FastMCP servers extract the version from `_meta.fastmcp.version` before processing. +For generic MCP clients that don't have built-in version support, pass the version through the `_meta` field in arguments. FastMCP servers extract the version from `_meta.fastmcp.version` before processing. <CodeGroup> -```json Tool Call Request Params +```json Tool Call Arguments { - "name": "calculate", - "arguments": { - "x": 1, - "y": 2 - }, + "x": 1, + "y": 2, "_meta": { "fastmcp": { "version": "1.0" @@ -230,12 +224,9 @@ For generic MCP clients that don't have built-in version support, pass the versi } ``` -```json Prompt Request Params +```json Prompt Arguments { - "name": "summarize", - "arguments": { - "text": "Summarize this document..." - }, + "text": "Summarize this document...", "_meta": { "fastmcp": { "version": "1.0" diff --git a/docs/servers/visibility.mdx b/docs/servers/visibility.mdx index edd3127d4..509bd7068 100644 --- a/docs/servers/visibility.mdx +++ b/docs/servers/visibility.mdx @@ -57,27 +57,29 @@ mcp.enable(tags={"admin"}) # Clients now see all three tools ``` -## Targeting Components +## Keys and Tags -Every filter parameter narrows the same way: `enable()` and `disable()` act on the components matching all the criteria you supply. Reach for the simplest one that expresses your intent — usually names or tags. +Visibility filtering works with two identifiers: keys (for specific components) and tags (for groups). -### Names +### Component Keys -`names` matches components by their name, or by their URI for resources and templates. This is the common case. +Every component has a unique key in the format `{type}:{identifier}`. + +| Component | Key Format | Example | +|-----------|------------|---------| +| Tool | `tool:{name}` | `tool:delete_everything` | +| Resource | `resource:{uri}` | `resource:data://config` | +| Template | `template:{uri}` | `template:file://{path}` | +| Prompt | `prompt:{name}` | `prompt:analyze` | + +Use keys to target specific components. ```python # Disable a specific tool -mcp.disable(names={"delete_everything"}) +mcp.disable(keys={"tool:delete_everything"}) -# Disable several components at once -mcp.disable(names={"reset_system", "data://secrets"}) -``` - -A name matches across component types, so a tool and a prompt that share a name are both affected. Add `components` when you want only one type. - -```python -# Disable only the tool named "config", leaving the resource alone -mcp.disable(names={"config"}, components={"tool"}) +# Disable multiple specific components +mcp.disable(keys={"tool:reset_system", "resource:data://secrets"}) ``` ### Tags @@ -110,62 +112,15 @@ mcp.disable(tags={"dangerous"}) A component is disabled if it has **any** of the disabled tags. The component doesn't need all the tags; one match is enough. -### Versions +### Combining Keys and Tags -When a component has several registered versions, `names` matches every one of them. To act on a particular version, filter by `version` with a [`VersionSpec`](/servers/versioning). +You can specify both keys and tags in a single call. The filters combine additively. ```python -from fastmcp.utilities.versions import VersionSpec - -# Retire v1 of every versioned component, leaving later versions live -mcp.disable(version=VersionSpec(eq="v1")) +# Disable specific tools AND all dangerous-tagged components +mcp.disable(keys={"tool:debug_info"}, tags={"dangerous"}) ``` -### Component Keys - -`keys` targets components by their canonical key, which encodes type, identifier, and version together. It is the only filter that can single out **one specific version of one specific component** — use it when `names` would sweep too broadly and `version` would sweep across too many components. - -```python -# Disable only v1 of search, leaving v1 of every other component untouched -mcp.disable(keys={"tool:search@v1"}) -``` - -Keys take the form `{type}:{identifier}@{version}`, where the `@` separates the identifier from the version and is **always present**. An unversioned component has an empty version, so its key ends in a bare `@`. - -| Component | Key Format | Example | -|-----------|------------|---------| -| Tool | `tool:{name}@{version}` | `tool:delete_everything@` | -| Resource | `resource:{uri}@{version}` | `resource:data://config@` | -| Template | `template:{uri_template}@{version}` | `template:file://{path}@` | -| Prompt | `prompt:{name}@{version}` | `prompt:analyze@` | - -The delimiter is unconditional because resource URIs may themselves contain `@`. Always emitting it means a key is parsed by splitting on the last `@`, so `resource:data://user@example.com/profile@` is unambiguous. - -<Warning> -Keys are matched by exact string equality, so a key that omits the trailing `@` — `tool:delete_everything` rather than `tool:delete_everything@` — matches nothing. FastMCP raises a `UserWarning` when it sees a key with no `@`, since such a key can never match. Prefer `names` unless you need version-level precision, and read a key off `component.key` rather than assembling it by hand. -</Warning> - -### Combining Filters - -Criteria in a single call **narrow** each other: a component must satisfy every one of them to match. Combining a name with a tag targets the intersection, not the union. - -```python -# Disables debug_info only if it is ALSO tagged "dangerous" -mcp.disable(names={"debug_info"}, tags={"dangerous"}) -``` - -To act on a union, make one call per criterion. Because later calls override earlier ones only where they overlap, successive disables accumulate. - -```python -# Disables debug_info AND everything tagged "dangerous" -mcp.disable(names={"debug_info"}) -mcp.disable(tags={"dangerous"}) -``` - -<Warning> -Intersection is easy to misread as union, and a rule that matches nothing fails silently. `disable(names={"debug_info"}, tags={"dangerous"})` disables nothing at all when `debug_info` lacks that tag — the components you meant to hide stay exposed. -</Warning> - ## Allowlist Mode By default, visibility filtering uses blocklist mode: everything is enabled unless explicitly disabled. The `only=True` parameter switches to allowlist mode, where **only** specified components are enabled. @@ -210,7 +165,7 @@ When you call `enable(only=True)`: ```python # Start fresh - only enable these specific tools -mcp.enable(names={"safe_read", "safe_write"}, only=True) +mcp.enable(keys={"tool:safe_read", "tool:safe_write"}, only=True) # Later, switch to a different allowlist mcp.enable(tags={"production"}, only=True) @@ -222,7 +177,7 @@ Later `enable()` and `disable()` calls override earlier ones. This lets you crea ```python mcp.enable(tags={"api"}, only=True) # Allow all api-tagged -mcp.disable(names={"api_admin"}) # Later disable overrides for this tool +mcp.disable(keys={"tool:api_admin"}) # Later disable overrides for this tool # api_admin is disabled because the later disable() overrides the allowlist ``` @@ -367,7 +322,7 @@ The session visibility methods accept the same filter criteria as `server.enable | Parameter | Description | |-----------|-------------| | `names` | Component names or URIs to match | -| `keys` | Component keys (e.g., `{"tool:my_tool@"}` for an unversioned tool, or `{"tool:my_tool@v1"}` for a versioned tool) | +| `keys` | Component keys (e.g., `{"tool:my_tool"}`) | | `tags` | Tags to match (component must have at least one) | | `version` | Version specification to match | | `components` | Component types (`{"tool"}`, `{"resource"}`, `{"prompt"}`, `{"template"}`) | diff --git a/docs/tutorials/mcp.mdx b/docs/tutorials/mcp.mdx index 34b1c86c2..fd3995fff 100644 --- a/docs/tutorials/mcp.mdx +++ b/docs/tutorials/mcp.mdx @@ -21,7 +21,7 @@ The answer lies in **standardization**. The AI ecosystem is fragmented. Every mo 1. **Interoperability:** Build one MCP server, and it can be used by any MCP-compliant client (Claude, Gemini, OpenAI, custom agents, etc.) without custom integration code. This is the protocol's most important promise. 2. **Discoverability:** Clients can dynamically ask a server what it's capable of at runtime. They receive a structured, machine-readable "menu" of tools and resources. -3. **Explicit boundaries:** MCP gives hosts and servers a typed inventory of the capabilities they expose. That creates a clear place to apply authorization, user confirmation, input validation, and sandboxing; the protocol defines the interface, while your application supplies those security policies. +3. **Security & Safety:** MCP provides a clear, sandboxed boundary. An LLM can't execute arbitrary code on your server; it can only *request* to run the specific, typed, and validated functions you explicitly expose. 4. **Composability:** You can build small, specialized MCP servers and combine them to create powerful, complex applications. ## Core MCP Components @@ -111,6 +111,10 @@ def summarize_text(text_to_summarize: str) -> str: ## Advanced Capabilities -Beyond tools, resources, and prompts, MCP supports richer interaction patterns such as notifications, progress updates, user elicitation, and argument completion. Extensions add capabilities such as durable background tasks. +Beyond the core components, MCP also supports more advanced interaction patterns, such as a server requesting that the *client's* LLM generate a completion (known as **sampling**), or a server sending asynchronous **notifications** to a client. These features enable more complex, bidirectional workflows and are fully supported by FastMCP. -FastMCP exposes these patterns through typed Python APIs. For example, [elicitation](/servers/elicitation) lets tools request missing information or confirmation, while [background tasks](/servers/tasks) let long-running work continue after the original request returns. +## Next Steps + +Now that you understand the core concepts of the Model Context Protocol, you're ready to start building. The best place to begin is our step-by-step tutorial. + +[**Tutorial: How to Create an MCP Server in Python →**](/tutorials/create-mcp-server) diff --git a/docs/updates.mdx b/docs/updates.mdx index 26e83c917..0faf12da9 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -5,50 +5,6 @@ icon: "sparkles" tag: NEW --- -<Update label="FastMCP 3.4.6" description="August 5, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.4.6: Trust, but Proxy" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.6" -cta="Read the release notes" -> -FastMCP 3.4.6 adds trusted-proxy support for SSRF-protected OAuth metadata and JWKS fetches on the 3.x line. Deployments can route these requests through a mandated corporate proxy while preserving custom CA certificates, and FastMCP refuses the fetch when no proxy is configured instead of risking an unprotected direct request. -</Card> -</Update> - -<Update label="FastMCP 4.0.0b1" description="July 28, 2026" tags={["Releases"]}> -<Card -title="FastMCP v4.0.0b1: Fourgone Conclusion" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v4.0.0b1" -cta="Read the release notes" -> -FastMCP 4 makes stateful MCP applications work on the sessionless protocol while one deployment continues serving handshake-era clients. The engine underneath changed completely, but FastMCP absorbs nearly all of it — most FastMCP 3 servers upgrade untouched. - -🌐 **Every protocol era** — one server answers both the sessionless `2026-07-28` protocol and the older session-based handshake, negotiated per connection. - -💬 **Interactive tools** — tools ask follow-up questions across complete request-response rounds, with shared request-state keys for load balancing and worker restarts. - -💾 **State without a session** — `UserSession` and `SessionId` give tools explicit server-side state on a protocol that deliberately has none, keyed per user when the request is authenticated. - -⏳ **Background tasks** — the `io.modelcontextprotocol/tasks` extension in the new `fastmcp-tasks` package, on the same Docket engine FastMCP 3 used. - -🧩 **Server extensions** — `add_extension()` turns capability-negotiated protocol features into a supported plugin surface. - -🔐 **Enterprise auth** — server-side identity assertion (SEP-990), `require_roles`, scope step-up challenges, and DCR `application_type`. - -⚠️ **Breaking** — server-initiated sampling and roots are removed from the server API, and the 3.x-era compatibility shims are gone. See the [upgrade guide](/getting-started/upgrading/from-fastmcp-3). -</Card> -</Update> - -<Update label="FastMCP 3.4.5" description="July 27, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.4.5: Key Change" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.5" -cta="Read the release notes" -> -A maintenance release for the 3.x line. A single unrecognized JWKS key — Ed25519, which Rauthy and Ory Hydra publish by default — no longer poisons the entire key cache, alongside fixes for Azure scope fallback, OpenAPI `deepObject` query serialization, schema compression, and transformed tool `required` ordering. -</Card> -</Update> - <Update label="FastMCP 3.4.4" description="July 8, 2026" tags={["Releases"]}> <Card title="FastMCP v3.4.4: Host in Translation" diff --git a/docs/v2/changelog.mdx b/docs/v2/changelog.mdx index e167f016b..6e9979277 100644 --- a/docs/v2/changelog.mdx +++ b/docs/v2/changelog.mdx @@ -550,7 +550,7 @@ Thank you to our new contributors and everyone who tested preview builds. Your f * Add configurable redirect URI validation for OAuth providers by [@jlowin](https://github.com/jlowin) in [#1582](https://github.com/PrefectHQ/fastmcp/pull/1582) * Remove invalid-argument-type ignore and fix type errors by [@jlowin](https://github.com/jlowin) in [#1588](https://github.com/PrefectHQ/fastmcp/pull/1588) * Remove generate-schema from public CLI by [@jlowin](https://github.com/jlowin) in [#1591](https://github.com/PrefectHQ/fastmcp/pull/1591) -* Skip flaky windows test / multi-client garbage collection by [@jlowin](https://github.com/jlowin) in [#1592](https://github.com/PrefectHQ/fastmcp/pull/1592) +* Skip flaky windows test / mulit-client garbage collection by [@jlowin](https://github.com/jlowin) in [#1592](https://github.com/PrefectHQ/fastmcp/pull/1592) * Add setting to disable logging configuration by [@isra17](https://github.com/isra17) in [#1575](https://github.com/PrefectHQ/fastmcp/pull/1575) * Improve debug logging for nested Servers / Clients by [@strawgate](https://github.com/strawgate) in [#1604](https://github.com/PrefectHQ/fastmcp/pull/1604) * Add GitHub pull request template by [@strawgate](https://github.com/strawgate) in [#1581](https://github.com/PrefectHQ/fastmcp/pull/1581) @@ -2339,4 +2339,4 @@ This release is highlighted by the ability to handle complex JSON objects as MCP The very first release of FastMCP! 🎉 **Full Changelog**: [Initial commits](https://github.com/PrefectHQ/fastmcp/commits/v0.1.0) -</Update> +</Update> \ No newline at end of file diff --git a/docs/v2/clients/sampling.mdx b/docs/v2/clients/sampling.mdx index 8f72de597..a709b1761 100644 --- a/docs/v2/clients/sampling.mdx +++ b/docs/v2/clients/sampling.mdx @@ -212,7 +212,7 @@ client = Client( ``` <Note> -Install the OpenAI handler with `pip install 'fastmcp[openai]'`. +Install the OpenAI handler with `pip install fastmcp[openai]`. </Note> ### Anthropic Handler @@ -246,7 +246,7 @@ client = Client( ``` <Note> -Install the Anthropic handler with `pip install 'fastmcp[anthropic]'`. +Install the Anthropic handler with `pip install fastmcp[anthropic]`. </Note> ### Tool Execution diff --git a/docs/integrations/images/permit/role_assignment.png b/docs/v2/integrations/images/permit/role_assignement.png similarity index 100% rename from docs/integrations/images/permit/role_assignment.png rename to docs/v2/integrations/images/permit/role_assignement.png diff --git a/docs/v2/integrations/images/permit/role_assignment.png b/docs/v2/integrations/images/permit/role_assignment.png deleted file mode 100644 index c65e34181..000000000 Binary files a/docs/v2/integrations/images/permit/role_assignment.png and /dev/null differ diff --git a/docs/v2/integrations/permit.mdx b/docs/v2/integrations/permit.mdx index ddda7cd2c..066f5b1ea 100644 --- a/docs/v2/integrations/permit.mdx +++ b/docs/v2/integrations/permit.mdx @@ -31,7 +31,7 @@ The middleware automatically maps MCP methods to Permit.io resources and actions > **Note:** > Don't forget to assign the relevant role (e.g., Admin, User) to the user authenticating to your MCP server (such as the user in the JWT) in the Permit.io Directory. Without the correct role assignment, users will not have access to the resources and actions you've configured in your policies. > -> ![Permit.io Directory Role Assignment Example](./images/permit/role_assignment.png) +> ![Permit.io Directory Role Assignment Example](./images/permit/role_assignement.png) > > *Example: In Permit.io Directory, both 'client' and 'admin' users are assigned the 'Admin' role, granting them the permissions defined in your policy mapping.* diff --git a/docs/v2/servers/auth/oauth-proxy.mdx b/docs/v2/servers/auth/oauth-proxy.mdx index 678c396b5..eef3bce1c 100644 --- a/docs/v2/servers/auth/oauth-proxy.mdx +++ b/docs/v2/servers/auth/oauth-proxy.mdx @@ -296,10 +296,8 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) **`"remember"` — silent consent on return:** Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class. - **`"external"` — externally managed:** - Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections. - - Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections. + **`"external"` — delegate to upstream:** + Skip the built-in consent page; consent is collected by the upstream IdP or a custom login page referenced via `upstream_authorization_endpoint`. No security warning is logged. **`False` — disable entirely:** Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing. @@ -319,7 +317,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) ``` <Warning> - Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow. + Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients. </Warning> </ParamField> diff --git a/docs/v2/servers/auth/oidc-proxy.mdx b/docs/v2/servers/auth/oidc-proxy.mdx index a7988995e..750298298 100644 --- a/docs/v2/servers/auth/oidc-proxy.mdx +++ b/docs/v2/servers/auth/oidc-proxy.mdx @@ -199,7 +199,7 @@ auth = OIDCProxy( </ParamField> <ParamField body="require_authorization_consent" type='bool | Literal["remember", "external"]' default="True"> - Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/v2/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs. + Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (consent handled by upstream IdP or custom page), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/v2/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs. </ParamField> <ParamField body="consent_csp_policy" type="str | None" default="None"> diff --git a/docs/v2/servers/sampling.mdx b/docs/v2/servers/sampling.mdx index 150c9ac5f..5c21a0bde 100644 --- a/docs/v2/servers/sampling.mdx +++ b/docs/v2/servers/sampling.mdx @@ -446,7 +446,7 @@ Client support for sampling is optional—some clients may not implement it. To FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/v2/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format. <Note> -Install handlers with `pip install 'fastmcp[openai]'` or `pip install 'fastmcp[anthropic]'`. +Install handlers with `pip install fastmcp[openai]` or `pip install fastmcp[anthropic]`. </Note> ```python diff --git a/docs/v3-banner.js b/docs/v3-banner.js deleted file mode 100644 index 6ddaa2d73..000000000 --- a/docs/v3-banner.js +++ /dev/null @@ -1,39 +0,0 @@ -// Add v3 banner inside content-container with negative margins -(function() { - if (typeof window === 'undefined') return; - - function addBanner() { - const isV3 = window.location.pathname.includes('/v3/'); - const container = document.getElementById('content-container'); - let banner = document.getElementById('v3-banner'); - - if (isV3 && container) { - if (!banner) { - banner = document.createElement('div'); - banner.id = 'v3-banner'; - banner.innerHTML = 'These are the docs for FastMCP 3. <a href="/getting-started/welcome" style="color: white; text-decoration: underline; font-weight: 700;">FastMCP 4</a> is now available.'; - container.insertBefore(banner, container.firstChild); - } - } else if (!isV3 && banner) { - banner.remove(); - } - } - - function run() { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', addBanner); - } else { - addBanner(); - } - } - - run(); - - let lastUrl = location.href; - new MutationObserver(() => { - if (location.href !== lastUrl) { - lastUrl = location.href; - setTimeout(addBanner, 100); - } - }).observe(document.body, {subtree: true, childList: true}); -})(); diff --git a/docs/v3-navigation.json b/docs/v3-navigation.json deleted file mode 100644 index 24c451b2a..000000000 --- a/docs/v3-navigation.json +++ /dev/null @@ -1,312 +0,0 @@ -{ - "dropdowns": [ - { - "dropdown": "Documentation", - "groups": [ - { - "group": "Get Started", - "pages": [ - "v3/getting-started/welcome", - "v3/getting-started/installation", - "v3/getting-started/quickstart" - ] - }, - { - "group": "Servers", - "pages": [ - "v3/servers/server", - { - "collapsed": true, - "group": "Core Components", - "icon": "toolbox", - "pages": [ - "v3/servers/tools", - "v3/servers/resources", - "v3/servers/prompts", - "v3/servers/context" - ] - }, - { - "collapsed": true, - "group": "Working with Tools", - "icon": "wand-magic-sparkles", - "pages": [ - "v3/servers/transforms/transforms", - "v3/servers/transforms/tool-transformation", - "v3/servers/transforms/code-mode", - "v3/servers/transforms/tool-search", - "v3/servers/transforms/namespace", - "v3/servers/visibility", - "v3/servers/transforms/resources-as-tools", - "v3/servers/transforms/prompts-as-tools", - "v3/servers/tool-fingerprinting" - ] - }, - { - "collapsed": true, - "group": "MCP Providers", - "icon": "layer-group", - "pages": [ - "v3/servers/providers/overview", - "v3/servers/providers/local", - "v3/servers/providers/filesystem", - "v3/servers/providers/proxy", - "v3/servers/providers/skills", - "v3/servers/composition", - "v3/servers/providers/custom" - ] - }, - { - "collapsed": true, - "group": "Interactivity", - "icon": "comments", - "pages": [ - "v3/servers/elicitation", - "v3/servers/sampling", - "v3/servers/progress", - "v3/servers/logging", - "v3/servers/pagination", - "v3/servers/icons" - ] - }, - { - "collapsed": true, - "group": "Extensibility", - "icon": "puzzle-piece", - "pages": [ - "v3/servers/middleware", - "v3/servers/dependency-injection", - "v3/servers/lifespan", - "v3/servers/storage-backends", - "v3/servers/tasks", - "v3/servers/versioning" - ] - }, - { - "collapsed": true, - "group": "Auth", - "icon": "shield-check", - "pages": [ - { - "collapsed": true, - "group": "Authentication", - "icon": "key", - "pages": [ - "v3/servers/auth/authentication", - "v3/servers/auth/token-verification", - "v3/servers/auth/remote-oauth", - "v3/servers/auth/oauth-proxy", - "v3/servers/auth/oidc-proxy", - "v3/servers/auth/full-oauth-server", - "v3/servers/auth/multi-auth" - ] - }, - "v3/servers/authorization" - ] - }, - { - "collapsed": true, - "group": "Deployment", - "icon": "rocket", - "pages": [ - "v3/deployment/running-server", - "v3/deployment/http", - "v3/deployment/sandboxed-agents", - "v3/deployment/prefect-horizon", - "v3/deployment/server-configuration", - "v3/servers/testing", - "v3/servers/telemetry" - ] - } - ] - }, - { - "group": "Apps", - "pages": [ - "v3/apps/overview", - "v3/apps/quickstart", - "v3/apps/fastmcp-app", - "v3/apps/prefab", - "v3/apps/generative", - "v3/apps/low-level", - { - "collapsed": true, - "group": "Reference", - "icon": "book", - "pages": [ - { - "collapsed": true, - "group": "Prefab Providers", - "icon": "cube", - "pages": [ - "v3/apps/providers/approval", - "v3/apps/providers/choice", - "v3/apps/providers/file-upload", - "v3/apps/providers/form" - ] - }, - "v3/apps/development", - "v3/apps/examples", - "v3/apps/architecture" - ] - } - ] - }, - { - "group": "Clients", - "pages": [ - "v3/clients/client", - "v3/clients/client-only-package", - "v3/clients/transports", - "v3/clients/fastmcp-remote", - { - "collapsed": true, - "group": "Operations", - "icon": "toolbox", - "pages": [ - "v3/clients/tools", - "v3/clients/resources", - "v3/clients/prompts", - "v3/clients/sampling", - "v3/clients/elicitation", - "v3/clients/tasks", - "v3/clients/progress", - "v3/clients/logging", - "v3/clients/roots", - "v3/clients/notifications" - ], - "tag": "UPDATED" - }, - { - "collapsed": true, - "group": "Authentication", - "icon": "key", - "pages": [ - "v3/clients/auth/oauth", - "v3/clients/auth/cimd", - "v3/clients/auth/bearer" - ], - "tag": "UPDATED" - } - ] - }, - { - "group": "Integrations", - "pages": [ - { - "collapsed": true, - "group": "Auth", - "icon": "key", - "pages": [ - "v3/integrations/auth0", - "v3/integrations/authkit", - "v3/integrations/aws-cognito", - "v3/integrations/azure", - "v3/integrations/descope", - "v3/integrations/discord", - "v3/integrations/eunomia-authorization", - "v3/integrations/github", - "v3/integrations/google", - "v3/integrations/huggingface", - "v3/integrations/keycloak", - "v3/integrations/oci", - "v3/integrations/permit", - "v3/integrations/propelauth", - "v3/integrations/scalekit", - "v3/integrations/supabase", - "v3/integrations/workos" - ] - }, - { - "collapsed": true, - "group": "Web Frameworks", - "icon": "code", - "pages": [ - "v3/integrations/fastapi", - "v3/integrations/openapi" - ] - }, - { - "collapsed": true, - "group": "AI Assistants", - "icon": "robot", - "pages": [ - "v3/integrations/chatgpt", - "v3/integrations/claude-code", - "v3/integrations/claude-desktop", - "v3/integrations/cursor", - "v3/integrations/gemini-cli", - "v3/integrations/goose" - ] - }, - { - "collapsed": true, - "group": "AI SDKs", - "icon": "microchip", - "pages": [ - "v3/integrations/anthropic", - "v3/integrations/gemini", - "v3/integrations/openai", - "v3/integrations/pydantic-ai" - ] - }, - "v3/integrations/mcp-json-configuration" - ] - }, - { - "group": "More", - "pages": [ - "v3/more/settings", - { - "collapsed": true, - "group": "CLI", - "icon": "terminal", - "pages": [ - "v3/cli/overview", - "v3/cli/running", - "v3/cli/install-mcp", - "v3/cli/inspecting", - "v3/cli/client", - "v3/cli/generate-cli", - "v3/cli/auth" - ] - }, - { - "collapsed": true, - "group": "Upgrading", - "icon": "up", - "pages": [ - "v3/getting-started/upgrading/from-fastmcp-2", - "v3/getting-started/upgrading/from-mcp-sdk", - "v3/getting-started/upgrading/from-low-level-sdk" - ] - }, - { - "collapsed": true, - "group": "Development", - "icon": "code", - "pages": [ - "v3/development/contributing", - "v3/development/tests", - "v3/development/releases", - "v3/patterns/contrib" - ] - }, - { - "collapsed": true, - "group": "What's New", - "icon": "sparkles", - "pages": [ - "v3/updates", - "v3/changelog" - ] - }, - "v3/more/faq" - ] - } - ], - "icon": "book" - } - ], - "version": "v3.4.4" -} diff --git a/docs/v3/apps/architecture.mdx b/docs/v3/apps/architecture.mdx deleted file mode 100644 index 7ecab2aa3..000000000 --- a/docs/v3/apps/architecture.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Architecture -sidebarTitle: Architecture -description: How FastMCP apps work under the hood — from Python to pixels. -icon: sitemap ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.2.0" /> - -You don't need this page to build apps. It's for when something isn't rendering the way you expect, when UI tool calls aren't reaching your server, or when you're writing [custom HTML apps](/apps/low-level) and need to understand the protocol directly. - -## The pipeline - -An MCP app moves through five stages from Python to pixels: - -``` -Python components → JSON tree → structuredContent → Renderer iframe → Host UI -``` - -You write Prefab components. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON in, and the renderer paints the UI. If the UI calls server tools, it talks back through the same `postMessage` channel. - -The sections below walk each stage. - -## Tool registration - -When you mark a tool with `app=True` or `@app.ui()`, FastMCP wires up the metadata and renderer resource that the protocol requires. - -### The `app=True` flag - -`app` on `@mcp.tool` accepts `True`, an `AppConfig`, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If it qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict. - -This expansion also registers the shared Prefab renderer resource (below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`" and the host fetches that resource when it displays the result. - -Type inference works the same way. If the return type is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`. - -### FastMCPApp registration - -`FastMCPApp` uses the same mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. That tag lets the server identify which app a tool belongs to when routing UI calls. - -Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (LLM-visible). Backend tools default to `["app"]` (UI-only). Hosts use this to filter the tool list. - -## Serialization - -When a Prefab tool runs, its return value — a `PrefabApp` or a bare `Component` — becomes a JSON blob the renderer can interpret. - -### `PrefabApp.to_json()` - -The entry point is `PrefabApp.to_json()`. It walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata). - -FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` on the wire. The resolver also handles `unwrap_result` — a flag telling the renderer to unwrap single-value results from the `{"result": value}` envelope FastMCP uses for schema compliance. - -### The `_meta.fastmcp.app` tag - -After `to_json()` produces the tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer. - -When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms (below). - -### ToolResult assembly - -The final tool result has two parts: `content` (a list of `TextContent` blocks for the LLM) and `structuredContent` (the JSON tree for the renderer). By default, Prefab tools send `"[Rendered Prefab UI]"` as the text content — just enough for the LLM to know something was rendered. If you return a `ToolResult` explicitly, you control both halves. - -## Tool call routing - -Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters) before resolving by name. App UI calls need a different path. - -### The `get_app_tool` bypass - -Backend tools are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — while the renderer still uses the original name. - -`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This walks the provider tree directly, skipping transforms. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app. - -That's why `CallTool("save_contact")` keeps working when the server is mounted under a namespace. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find it without transforms in the way. - -Authorization still applies. `get_app_tool` bypasses transforms but runs auth checks against the tool's `auth` config before executing. - -### Provider delegation - -`get_app_tool` is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. Backend tools are reachable through any depth of composition. - -## The renderer - -The Prefab renderer is a self-contained JavaScript application that interprets the JSON component tree and renders it as a React UI. - -### The shared resource - -FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The HTML is bundled inside the `prefab-ui` Python package; `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource. - -The resource also carries CSP metadata (via `get_renderer_csp()`) declaring the CDN domains the renderer needs. Hosts use this to configure the iframe's Content Security Policy. - -### `postMessage` communication - -The renderer lives in a sandboxed iframe and communicates with the host using `postMessage`. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) spec: - -The host pushes the tool result (with `structuredContent`) into the iframe. The renderer parses the component tree, initializes state, and renders the UI. When the user interacts — submitting a form, clicking a button — and the interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards it as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing. - -The response flows back the same way: server → host → iframe via `postMessage`, and the renderer updates state with the result. - -### AppBridge - -The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (safe area insets, theme preferences). The Prefab renderer uses it internally; you only touch it directly when building [custom HTML apps](/apps/low-level). - -## The dev server - -`fastmcp dev apps` simulates the host-side behavior locally without a real MCP client. - -### Proxy architecture - -Two HTTP servers. Your MCP server runs on port 8000 with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools. - -A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This matters because the renderer iframe runs on `localhost:8080` and your MCP server runs on `localhost:8000` — without the proxy, the renderer's `callServerTool` requests would be cross-origin and the browser would block them. The proxy keeps everything same-origin from the iframe's perspective. - -### The launch flow - -When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (via the proxy), creates an AppBridge, and pushes the tool result into the renderer. From here on it matches what a real host provides: the renderer displays the UI, and any `CallTool` actions route back through the proxy to your server. - -Auto-reload is on by default, so changes to your server code restart the MCP server automatically. The dev UI keeps running — relaunch the tool to see changes. diff --git a/docs/v3/apps/demos/bar-chart.py b/docs/v3/apps/demos/bar-chart.py deleted file mode 100644 index e2430b981..000000000 --- a/docs/v3/apps/demos/bar-chart.py +++ /dev/null @@ -1,23 +0,0 @@ -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column -from prefab_ui.components.charts import BarChart, ChartSeries - -data = [ - {"quarter": "Q1", "revenue": 42000, "costs": 28000}, - {"quarter": "Q2", "revenue": 51000, "costs": 31000}, - {"quarter": "Q3", "revenue": 47000, "costs": 29000}, - {"quarter": "Q4", "revenue": 63000, "costs": 35000}, -] - -with PrefabApp() as app: - with Column(css_class="p-6"): - BarChart( - data=data, - series=[ - ChartSeries(data_key="revenue", label="Revenue"), - ChartSeries(data_key="costs", label="Costs"), - ], - x_axis="quarter", - show_legend=True, - height=250, - ) diff --git a/docs/v3/apps/demos/contacts.py b/docs/v3/apps/demos/contacts.py deleted file mode 100644 index 0cbe60c0b..000000000 --- a/docs/v3/apps/demos/contacts.py +++ /dev/null @@ -1,78 +0,0 @@ -from prefab_ui.actions import ShowToast -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - H3, - Badge, - Button, - Column, - DataTable, - DataTableColumn, - Form, - Input, - Row, - Select, - SelectOption, - Separator, -) - -contacts = [ - {"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"}, - {"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"}, - { - "name": "Trillian Astra", - "email": "trillian@heartofgold.com", - "category": "Customer", - }, - {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Vendor"}, -] - -rows = [ - { - "name": c["name"], - "email": c["email"], - "category": Badge( - c["category"], - variant="success" - if c["category"] == "Customer" - else "secondary" - if c["category"] == "Partner" - else "outline", - ), - } - for c in contacts -] - -with PrefabApp() as app: - with Column(gap=4, css_class="p-6"): - DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="email", header="Email"), - DataTableColumn(key="category", header="Category"), - ], - rows=rows, - search=True, - ) - - Separator() - - H3("Add Contact") - with Form( - on_submit=ShowToast( - "Contact saved! (preview demo — no backend wired)", - variant="success", - ), - ): - with Row(gap=4): - Input(name="name", label="Name", placeholder="Full name", required=True) - Input( - name="email", - label="Email", - placeholder="name@example.com", - required=True, - ) - with Select(name="category", label="Category"): - SelectOption(value="Customer", label="Customer") - SelectOption(value="Partner", label="Partner") - SelectOption(value="Vendor", label="Vendor") - Button("Save Contact") diff --git a/docs/v3/apps/demos/dashboard.py b/docs/v3/apps/demos/dashboard.py deleted file mode 100644 index 06fe6285d..000000000 --- a/docs/v3/apps/demos/dashboard.py +++ /dev/null @@ -1,68 +0,0 @@ -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Badge, - Column, - DataTable, - DataTableColumn, - Row, - Separator, -) -from prefab_ui.components.charts import BarChart, ChartSeries -from prefab_ui.components.metric import Metric - -monthly = [ - {"month": "Jan", "revenue": 48200, "costs": 31000}, - {"month": "Feb", "revenue": 52100, "costs": 32500}, - {"month": "Mar", "revenue": 61800, "costs": 34200}, - {"month": "Apr", "revenue": 58400, "costs": 33800}, -] - -deals = [ - {"account": "Acme Corp", "value": "$84,000", "stage": "Won"}, - {"account": "Globex Inc", "value": "$52,000", "stage": "Negotiation"}, - {"account": "Initech", "value": "$31,500", "stage": "Proposal"}, - {"account": "Wayne Enterprises", "value": "$45,000", "stage": "Lost"}, -] - -rows = [ - { - "account": d["account"], - "value": d["value"], - "stage": Badge( - d["stage"], - variant="success" - if d["stage"] == "Won" - else "destructive" - if d["stage"] == "Lost" - else "secondary", - ), - } - for d in deals -] - -total = sum(m["revenue"] for m in monthly) - -with PrefabApp() as app: - with Column(gap=4, css_class="p-6"): - with Row(gap=6): - Metric(label="Revenue (Q1-Q4)", value=f"${total:,}") - Metric(label="Deals", value=f"{len(deals)}") - BarChart( - data=monthly, - series=[ - ChartSeries(data_key="revenue", label="Revenue"), - ChartSeries(data_key="costs", label="Costs"), - ], - x_axis="month", - show_legend=True, - height=200, - ) - Separator() - DataTable( - columns=[ - DataTableColumn(key="account", header="Account", sortable=True), - DataTableColumn(key="value", header="Value", sortable=True), - DataTableColumn(key="stage", header="Stage"), - ], - rows=rows, - ) diff --git a/docs/v3/apps/demos/data-table.py b/docs/v3/apps/demos/data-table.py deleted file mode 100644 index 5100237bf..000000000 --- a/docs/v3/apps/demos/data-table.py +++ /dev/null @@ -1,24 +0,0 @@ -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, DataTable, DataTableColumn - -employees = [ - {"name": "Alice Chen", "role": "Staff Engineer", "dept": "Platform"}, - {"name": "Bob Martinez", "role": "Lead Designer", "dept": "Design"}, - {"name": "Carol Johnson", "role": "Senior Engineer", "dept": "Platform"}, - {"name": "David Kim", "role": "Product Manager", "dept": "Product"}, - {"name": "Eva Mueller", "role": "Engineer", "dept": "Platform"}, - {"name": "Frank Lee", "role": "Data Scientist", "dept": "ML"}, - {"name": "Grace Park", "role": "Eng Manager", "dept": "Platform"}, -] - -with PrefabApp() as app: - with Column(gap=4, css_class="p-6"): - DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="role", header="Role", sortable=True), - DataTableColumn(key="dept", header="Dept", sortable=True), - ], - rows=employees, - search=True, - ) diff --git a/docs/v3/apps/demos/hitchhikers.py b/docs/v3/apps/demos/hitchhikers.py deleted file mode 100644 index 1554e5165..000000000 --- a/docs/v3/apps/demos/hitchhikers.py +++ /dev/null @@ -1,461 +0,0 @@ -"""The Hitchhiker's Guide dashboard from the Prefab welcome page. - -Run with: - prefab serve examples/hitchhikers-guide/dashboard.py - prefab export examples/hitchhikers-guide/dashboard.py -""" - -from prefab_ui import PrefabApp -from prefab_ui.actions import SetInterval, SetState, ShowToast -from prefab_ui.components import ( - Alert, - AlertDescription, - AlertTitle, - Badge, - Button, - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, - Carousel, - Checkbox, - Column, - Combobox, - ComboboxOption, - DataTable, - DataTableColumn, - DatePicker, - Dialog, - Grid, - GridItem, - HoverCard, - Loader, - Metric, - Muted, - P, - Progress, - Radio, - RadioGroup, - Ring, - Row, - Separator, - Slider, - Switch, - Text, - Tooltip, -) -from prefab_ui.components.charts import ( - BarChart, - ChartSeries, - RadarChart, - Sparkline, -) -from prefab_ui.components.control_flow import Else, If -from prefab_ui.rx import Rx - -ctx_tick = Rx("ctx_tick") - -# Context window: climbs from 24% to ~78%, then resets -ctx_pct = (ctx_tick % 20) * 3 + 20 -ctx_variant = (ctx_pct > 70).then( - "destructive", (ctx_pct <= 33).then("success", "default") -) - -with PrefabApp( - title="Prefab Showcase", - state={"ctx_tick": 0, "improbability": 42}, - on_mount=SetInterval( - 400, - on_tick=SetState("ctx_tick", ctx_tick + 1), - ), -) as app: - with Grid(columns={"default": 1, "md": 2, "lg": 4}, gap=4): - # ── Col 1 ───────────────────────────────────────────────────────── - with Column(gap=4): - with Card(): - with CardHeader(): - CardTitle("Register Towel") - CardDescription("The most important item in the galaxy") - with CardContent(): - with Column(gap=3): - with Combobox( - placeholder="Type...", - search_placeholder="Search types...", - ): - ComboboxOption("Bath", value="bath") - ComboboxOption("Beach", value="beach") - ComboboxOption("Interstellar", value="interstellar") - ComboboxOption("Microfiber", value="micro") - DatePicker(placeholder="Registration date") - with CardFooter(): - with Row(gap=2): - with Dialog( - title="Towel Registered!", - description="Your towel has been added to the galactic registry.", - ): - Button("Register") - Text("Don't forget to bring it.") - Button("Cancel", variant="outline") - with If("{{ !pressed }}"): - Button( - "This is probably the best button to press.", - variant="success", - on_click=SetState("pressed", True), - ) - with Else(): - Button( - "Please do not press this button again.", - variant="destructive", - on_click=SetState("pressed", False), - ) - - with Card(): - with CardHeader(): - CardTitle("Ship Status") - with CardContent(): - with Column(gap=3): - with Row( - align="center", - css_class="justify-between", - ): - Text("heart-of-gold") - with HoverCard(open_delay=0, close_delay=200): - Badge("In Orbit", variant="default") - with Column(gap=2): - Text("heart-of-gold") - Muted("Deployed 2h ago") - Progress( - value=100, - max=100, - variant="success", - ) - Progress( - value=100, - max=100, - indicator_class="bg-yellow-400", - ) - with Row( - align="center", - css_class="justify-between", - ): - Text("vogon-poetry") - with Tooltip("64% — ETA 12 min", delay=0): - with Badge(variant="secondary"): - Loader(size="sm") - Text("Deploying") - Progress(value=64, max=100) - with Row( - align="center", - css_class="justify-between", - ): - Text("deep-thought") - with Tooltip( - "Computing... 7.5 million years remaining", - delay=0, - ): - with Badge(variant="outline"): - Loader(size="sm", variant="ios") - Text("Soon...") - Progress(value=12, max=100) - with Card(): - with CardHeader(): - CardTitle("Planet Ratings") - with CardContent(): - RadarChart( - data=[ - {"axis": "Views", "earth": 30, "mag": 95}, - {"axis": "Fjords", "earth": 65, "mag": 100}, - {"axis": "Pubs", "earth": 90, "mag": 10}, - {"axis": "Mice", "earth": 40, "mag": 85}, - {"axis": "Tea", "earth": 95, "mag": 15}, - {"axis": "Safety", "earth": 45, "mag": 70}, - ], - series=[ - ChartSeries(dataKey="earth", label="Earth"), - ChartSeries(dataKey="mag", label="Magrathea"), - ], - axis_key="axis", - height=200, - show_legend=True, - show_tooltip=True, - ) - - # ── Col 2 ───────────────────────────────────────────────────────── - with Column(gap=4): - with Card(): - with CardHeader(): - CardTitle("Survival Odds") - with CardContent(css_class="w-fit mx-auto"): - Ring( - value=42, - label="42%", - variant="info", - size="lg", - thickness=12, - indicator_class="group-hover:drop-shadow-[0_0_24px_rgba(59,130,246,0.9)]", - ) - with Card(): - with CardHeader(): - with Row(gap=2, align="center"): - CardTitle("Improbability Drive") - Loader( - variant="pulse", - size="sm", - css_class="text-blue-500", - ) - with CardContent(): - with Column(gap=2): - Slider( - min=0, - max=100, - value=42, - name="improbability", - ) - with Row( - align="center", - css_class="justify-between", - ): - Muted("Probable") - Muted("Infinite") - with Carousel(auto_advance=3000, show_controls=False, direction="up"): - with Alert(variant="success", icon="circle-check"): - AlertTitle("Don't Panic") - AlertDescription("Normality achieved.") - with Alert(variant="destructive", icon="triangle-alert"): - AlertTitle("Display Department") - AlertDescription("Beware of the leopard.") - with Card(): - with CardHeader(): - CardTitle("Prefect Horizon Config") - with CardContent(): - with Column(gap=3): - Switch( - label="Auto-scale agents", - value=True, - name="autoscale", - ) - Separator() - Switch( - label="Code Mode", - value=True, - name="code_mode", - ) - Separator() - Switch( - label="Tool call caching", - value=False, - name="cache", - ) - with CardFooter(): - Button( - "Save Preferences", - on_click=ShowToast("Preferences saved!"), - ) - with Card(): - with CardHeader(): - CardTitle("Travel Class") - with CardContent(): - with RadioGroup(name="travel_class"): - Radio(option="economy", label="Economy") - Radio(option="business", label="Business Class") - Radio( - option="improbability", - label="Infinite Improbability", - value=True, - ) - - # ── Cols 3–4: summary row, chart, then 2-col grid below ───────── - with GridItem(css_class="md:col-span-2"): - with Column(gap=4): - with Grid(columns=2, gap=4, css_class="h-32"): - with Card(): - with CardHeader(): - CardTitle("Context Window") - with CardContent(): - with Column( - gap=6, - justify="center", - css_class="h-full", - ): - with Row( - align="center", - css_class="justify-between", - ): - Text(f"{ctx_pct}% used") - Muted(f"{ctx_pct * 2}k / 200k tokens") - with Tooltip( - "Auto-compact buffer: 12%", - delay=0, - ): - Progress( - value=ctx_pct, - max=100, - variant=ctx_variant, - ) - with Card(css_class="pb-0 gap-0"): - with CardContent(): - Metric( - label="Fjords designed", - value="1,847", - delta="+3 coastlines", - ) - Sparkline( - data=[ - 820, - 950, - 1100, - 980, - 1250, - 1400, - 1350, - 1500, - 1680, - 1847, - ], - variant="success", - fill=True, - css_class="h-16", - ) - with Card(): - with CardHeader(): - CardTitle("Towel Incidents") - with CardContent(): - BarChart( - data=[ - {"month": "Jan", "lost": 8, "found": 5}, - {"month": "Feb", "lost": 24, "found": 15}, - {"month": "Mar", "lost": 12, "found": 28}, - {"month": "Apr", "lost": 35, "found": 19}, - {"month": "May", "lost": 18, "found": 38}, - {"month": "Jun", "lost": 42, "found": 30}, - ], - series=[ - ChartSeries(dataKey="lost", label="Lost"), - ChartSeries(dataKey="found", label="Found"), - ], - x_axis="month", - height=200, - bar_radius=4, - show_legend=True, - show_tooltip=True, - show_grid=True, - ) - - with Grid(columns=2, gap=4): - with Column(gap=4): - with Card(): - with CardContent(): - with Column(gap=2): - Checkbox(label="Towel packed", value=True) - Checkbox(label="Guide charged", value=True) - Checkbox( - label="Babel fish inserted", - value=False, - ) - with Card(): - with CardHeader(): - CardTitle("Marvin's Mood") - with CardContent(): - with Column(gap=3): - P("How's life?") - with Column(gap=2): - Button( - "Meh", - on_click=ShowToast( - "Noted. Enthusiasm levels nominal." - ), - ) - Button( - "Depressed", - variant="info", - on_click=ShowToast( - "I think you ought to " - "know I'm feeling very " - "depressed." - ), - ) - Button( - "Don't talk to me about life", - variant="warning", - on_click=ShowToast( - "Brain the size of a " - "planet and they ask me " - "to pick up a piece of " - "paper." - ), - ) - - with Column(gap=4): - with Card(): - with CardContent(): - with Row(gap=2, align="center"): - Loader(variant="dots", size="sm") - Muted("Marvin is thinking...") - with Card(): - with CardContent(): - DataTable( - columns=[ - DataTableColumn( - key="crew", - header="Crew", - sortable=True, - ), - DataTableColumn( - key="species", - header="Species", - sortable=True, - ), - DataTableColumn( - key="towel", - header="Towel?", - sortable=True, - ), - DataTableColumn( - key="status", - header="Status", - sortable=True, - ), - ], - rows=[ - { - "crew": "Arthur Dent", - "species": "Human", - "towel": "Yes", - "status": "Confused", - }, - { - "crew": "Ford Prefect", - "species": "Betelgeusian", - "towel": "Always", - "status": "Drinking", - }, - { - "crew": "Zaphod", - "species": "Betelgeusian", - "towel": "Lost it", - "status": "Presidential", - }, - { - "crew": "Trillian", - "species": "Human", - "towel": "Yes", - "status": "Navigating", - }, - { - "crew": "Marvin", - "species": "Android", - "towel": "No point", - "status": "Depressed", - }, - { - "crew": "Slartibartfast", - "species": "Magrathean", - "towel": "Somewhere", - "status": "Designing", - }, - ], - search=True, - paginated=False, - ) diff --git a/docs/v3/apps/demos/pie-chart.py b/docs/v3/apps/demos/pie-chart.py deleted file mode 100644 index c1fb489e4..000000000 --- a/docs/v3/apps/demos/pie-chart.py +++ /dev/null @@ -1,21 +0,0 @@ -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column -from prefab_ui.components.charts import PieChart - -data = [ - {"category": "Bug", "count": 42}, - {"category": "Feature", "count": 28}, - {"category": "Docs", "count": 15}, - {"category": "Infra", "count": 10}, -] - -with PrefabApp() as app: - with Column(css_class="p-6"): - PieChart( - data=data, - data_key="count", - name_key="category", - inner_radius=50, - show_legend=True, - height=240, - ) diff --git a/docs/v3/apps/demos/reactive.py b/docs/v3/apps/demos/reactive.py deleted file mode 100644 index 16f2f9829..000000000 --- a/docs/v3/apps/demos/reactive.py +++ /dev/null @@ -1,66 +0,0 @@ -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Column, - Row, - Select, - SelectOption, - Switch, - Text, -) -from prefab_ui.components.charts import BarChart, ChartSeries -from prefab_ui.components.control_flow import If -from prefab_ui.components.metric import Metric -from prefab_ui.rx import Rx - -region = Rx("region") - -north = [ - {"month": "Jan", "sales": 22000}, - {"month": "Feb", "sales": 25500}, - {"month": "Mar", "sales": 24200}, -] -south = [ - {"month": "Jan", "sales": 5800}, - {"month": "Feb", "sales": 6400}, - {"month": "Mar", "sales": 5600}, -] -west = [ - {"month": "Jan", "sales": 6000}, - {"month": "Feb", "sales": 6000}, - {"month": "Mar", "sales": 5600}, -] - -with PrefabApp( - state={ - "region": "north", - "north": north, - "south": south, - "west": west, - "show_target": True, - }, -) as app: - with Column( - gap=4, - css_class="p-6", - let={ - "data": "{{ region == 'south' ? south : region == 'west' ? west : north }}", - }, - ): - with Row(gap=4, align="center"): - with Select(name="region", css_class="w-40"): - SelectOption(value="north", label="North") - SelectOption(value="south", label="South") - SelectOption(value="west", label="West") - Switch(name="show_target", css_class="ml-auto") - Text("Show target", css_class="text-sm text-muted-foreground") - BarChart( - data=Rx("data"), - series=[ChartSeries(data_key="sales", label="Sales")], - x_axis="month", - height=200, - ) - with If(Rx("show_target")): - Metric( - label="Q1 Target", - value="$75,000", - ) diff --git a/docs/v3/apps/demos/team-directory-reactive.py b/docs/v3/apps/demos/team-directory-reactive.py deleted file mode 100644 index b6aa004f7..000000000 --- a/docs/v3/apps/demos/team-directory-reactive.py +++ /dev/null @@ -1,116 +0,0 @@ -from collections import Counter - -from prefab_ui.actions import SetState -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - H3, - Badge, - Card, - CardContent, - CardHeader, - Column, - DataTable, - DataTableColumn, - Grid, - Row, - Small, - Text, -) -from prefab_ui.components.charts import PieChart -from prefab_ui.components.control_flow import If -from prefab_ui.rx import STATE, Rx - -MEMBERS = [ - { - "name": "Alice Chen", - "role": "Staff Engineer", - "office": "San Francisco", - "email": "alice@company.com", - "projects": 3, - }, - { - "name": "Bob Martinez", - "role": "Lead Designer", - "office": "New York", - "email": "bob@company.com", - "projects": 5, - }, - { - "name": "Carol Johnson", - "role": "Senior Engineer", - "office": "London", - "email": "carol@company.com", - "projects": 2, - }, - { - "name": "David Kim", - "role": "Product Manager", - "office": "San Francisco", - "email": "david@company.com", - "projects": 7, - }, - { - "name": "Eva Mueller", - "role": "Engineer", - "office": "Berlin", - "email": "eva@company.com", - "projects": 1, - }, - { - "name": "Frank Lee", - "role": "Data Scientist", - "office": "San Francisco", - "email": "frank@company.com", - "projects": 4, - }, - { - "name": "Grace Park", - "role": "Engineering Manager", - "office": "New York", - "email": "grace@company.com", - "projects": 6, - }, -] - -OFFICE_COUNTS = [ - {"office": office, "count": count} - for office, count in Counter(m["office"] for m in MEMBERS).items() -] - -with PrefabApp(state={"selected": None}) as app: - with Column(gap=4, css_class="p-6"): - with Grid(columns=[1, 2], gap=4): - PieChart( - data=OFFICE_COUNTS, - data_key="count", - name_key="office", - show_legend=True, - ) - DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="role", header="Role", sortable=True), - DataTableColumn(key="office", header="Office", sortable=True), - ], - rows=MEMBERS, - search=True, - on_row_click=SetState("selected", Rx("$event")), - ) - - with If(STATE.selected): - with Card(): - with CardHeader(): - with Row(gap=2, align="center"): - H3(Rx("selected.name")) - Badge(Rx("selected.office")) - with CardContent(): - with Grid(columns=3, gap=4): - with Column(gap=0): - Small("Role") - Text(Rx("selected.role")) - with Column(gap=0): - Small("Email") - Text(Rx("selected.email")) - with Column(gap=0): - Small("Active Projects") - Text(Rx("selected.projects")) diff --git a/docs/v3/apps/demos/team-directory.py b/docs/v3/apps/demos/team-directory.py deleted file mode 100644 index 7cfe21bc9..000000000 --- a/docs/v3/apps/demos/team-directory.py +++ /dev/null @@ -1,39 +0,0 @@ -from collections import Counter - -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, DataTable, DataTableColumn, Grid -from prefab_ui.components.charts import PieChart - -members = [ - {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco"}, - {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York"}, - {"name": "Carol Johnson", "role": "Senior Engineer", "office": "London"}, - {"name": "David Kim", "role": "Product Manager", "office": "San Francisco"}, - {"name": "Eva Mueller", "role": "Engineer", "office": "Berlin"}, - {"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco"}, - {"name": "Grace Park", "role": "Engineering Manager", "office": "New York"}, -] - -office_counts = [ - {"office": office, "count": count} - for office, count in Counter(m["office"] for m in members).items() -] - -with PrefabApp() as app: - with Column(gap=4, css_class="p-6"): - with Grid(columns=[1, 2], gap=4): - PieChart( - data=office_counts, - data_key="count", - name_key="office", - show_legend=True, - ) - DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="role", header="Role", sortable=True), - DataTableColumn(key="office", header="Office", sortable=True), - ], - rows=members, - search=True, - ) diff --git a/docs/v3/apps/development.mdx b/docs/v3/apps/development.mdx deleted file mode 100644 index 0d3a71ac7..000000000 --- a/docs/v3/apps/development.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Development -sidebarTitle: Development -description: Preview and test your app tools locally without a full MCP host. -icon: flask ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.2.0" /> - -<Frame> - <img src="/apps/images/dev-app.png" alt="The dev UI showing a rendered Prefab app with the MCP inspector panel" /> -</Frame> - -`fastmcp dev apps` gives you a browser preview for your app tools without needing an MCP host client. It starts your server and a local dev UI side by side: you pick a tool, fill in its arguments, and the rendered result opens in a new tab. - -Works with both [Interactive Tools](/apps/prefab) and [custom HTML apps](/apps/low-level). - -## Quick start - -```bash -fastmcp dev apps server.py -``` - -The dev UI opens at `http://localhost:8080`. Your MCP server runs on port 8000 with auto-reload enabled by default — save a file and the server restarts automatically. - -## How it works - -The dev server does three things: - -The **picker page** connects to your MCP server, finds all tools with UI metadata, and renders a form for each one. The forms are auto-generated from the tool's input schema — text fields, dropdowns, checkboxes, all wired up. - -When you submit a form, the dev server **calls your tool** via the MCP protocol and opens the result in a new tab. The result page loads the tool's UI resource (the Prefab renderer or your custom HTML) inside an AppBridge — the same protocol that real MCP hosts use. - -A **reverse proxy** on `/mcp` forwards requests from the browser to your MCP server, avoiding CORS issues that would otherwise block the iframe-based renderer from talking to a different port. - -## MCP inspector - -The dev UI includes an inspector panel on the left side that captures MCP traffic in real time. It shows JSON-RPC messages flowing between the browser and your server — requests, responses, and AppBridge `postMessage` traffic. - -Each entry shows direction, method, timing, and a smart summary. Click any entry to expand the full JSON-RPC body. The panel auto-scrolls to new messages unless you've scrolled up to inspect older ones. - -The inspector is useful for debugging: you can see exactly what arguments your tool received, what it returned, and how the AppBridge communicated with the renderer. - -## Options - -```bash -fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 --no-reload -``` - -| Option | Flag | Default | Description | -| ------ | ---- | ------- | ----------- | -| MCP Port | `--mcp-port` | `8000` | Port for your MCP server | -| Dev Port | `--dev-port` | `8080` | Port for the dev UI | -| Auto-Reload | `--reload` / `--no-reload` | On | Watch files and restart the server on changes | - -## Multiple tools - -If your server has multiple app tools, the picker shows a dropdown. Each tool gets its own form and launch button. The tool's `title` is displayed when available, falling back to the tool name. - -```bash -# Server with multiple app tools -fastmcp dev apps examples/apps/contacts/contacts_server.py -``` diff --git a/docs/v3/apps/examples.mdx b/docs/v3/apps/examples.mdx deleted file mode 100644 index 5078120e7..000000000 --- a/docs/v3/apps/examples.mdx +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Examples -sidebarTitle: Examples -description: Example apps you can run right now. -icon: images ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.2.0" /> - -Each tile below is a working FastMCP server you can run with `fastmcp dev apps` or connect to from any MCP host. Source lives in `examples/apps/` in the repository. - -<Columns cols={2}> - <Tile href="#sales-dashboard" title="Sales Dashboard" description="Metrics, charts, and deal pipeline"> - <div style={{overflow: "hidden", width: "100%"}}> - <img src="/apps/images/app-example-sales-dashboard.png" /> - </div> - </Tile> - <Tile href="#system-monitor" title="System Monitor" description="Live CPU, memory, disk with auto-refresh"> - <img src="/apps/images/app-example-system-dashboard.png" /> - </Tile> - <Tile href="#quiz" title="Quiz" description="LLM-generated trivia with scoring"> - <img src="/apps/images/app-example-quiz.png" /> - </Tile> - <Tile href="#interactive-map" title="Interactive Map" description="Geocoded addresses on Leaflet"> - <img src="/apps/images/app-example-map.png" /> - </Tile> - <Tile href="/apps/providers/file-upload" title="File Upload" description="Drag-and-drop upload provider"> - <img src="/apps/images/app-file-upload.png" /> - </Tile> - <Tile href="/apps/providers/approval" title="Approval" description="Human-in-the-loop confirmation"> - <img src="/apps/images/app-approval.png" /> - </Tile> - <Tile href="/apps/providers/choice" title="Choice" description="Clickable option selection"> - <img src="/apps/images/app-choice.png" /> - </Tile> - <Tile href="/apps/providers/form" title="Form Input" description="Pydantic model forms"> - <img src="/apps/images/app-form.png" /> - </Tile> - <Tile href="/apps/generative" title="Generative UI" description="LLM writes the UI at runtime"> - <img src="/apps/images/app-showcase.png" /> - </Tile> -</Columns> - -## Running the examples - -Preview any example in your browser with the dev server: - -```bash -pip install "fastmcp[apps]" -fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py -``` - -The dev UI lets you pick a tool and fill in arguments. In a real deployment the LLM provides those arguments from conversation context — the quiz example especially shines when connected to a host like Goose or Claude Desktop, where the LLM generates the questions itself. - -## Standalone apps - -### Sales dashboard - -A full dashboard with KPI metrics, revenue trends, segment breakdown, and a deal pipeline table. Shows what you can build with a single `app=True` tool and Prefab's chart and data components. - -```bash -fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py -``` - -### System monitor - -Reads live CPU, memory, and disk stats from your machine using `psutil`. Auto-refreshes via `SetInterval` calling a backend tool, with a dropdown to control the refresh rate. The chart accumulates up to 100 data points over time. - -```bash -pip install psutil -fastmcp dev apps examples/apps/system_monitor/system_monitor_server.py -``` - -### Quiz - -The LLM generates trivia questions and passes them to the tool. The user answers via buttons, sees correct/incorrect feedback, and tracks score across questions. Demonstrates multi-turn client-side state with FastMCPApp. - -```bash -fastmcp dev apps examples/apps/quiz/quiz_server.py -``` - -### Interactive map - -Accepts addresses or place names, geocodes them via OpenStreetMap Nominatim (free, no API key), and renders an interactive Leaflet map using Prefab's `Embed` component with inline HTML. A reminder that Prefab apps can break out of built-in components when they need to. - -```bash -fastmcp dev apps examples/apps/map/map_server.py -``` - -For ready-made building blocks like approvals, choice pickers, file uploads, and Pydantic forms, see the [Providers](/apps/providers/approval) group. diff --git a/docs/v3/apps/fastmcp-app.mdx b/docs/v3/apps/fastmcp-app.mdx deleted file mode 100644 index 55b3b7ed7..000000000 --- a/docs/v3/apps/fastmcp-app.mdx +++ /dev/null @@ -1,470 +0,0 @@ ---- -title: FastMCPApp -sidebarTitle: FastMCPApp -description: Wire an interactive UI to backend tools with managed visibility and composition safety. -icon: puzzle-piece -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' -import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx' -import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx' - -<VersionBadge version="3.2.0" /> - -<PrefabPinWarning /> - -<PrefabDemoFrame demo="contacts" height="650px" title="Contacts app demo" /> - -Search a list, fill out a form, click save, the list updates. That pattern — UI that reads and writes data on the server — needs two things: backend tools that actually do the work, and a way to call them from the UI. `FastMCPApp` handles the wiring. - -You'll build up to the contacts app above by the end of this page. Let's start with something smaller. - -## A minimal interactive app - -The smallest interactive app: a form that saves a note, and a list that updates when the user submits. - -```python -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.actions.mcp import CallTool -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Badge, Button, Column, ForEach, Form, Heading, - Input, Row, Separator, Text, -) -from prefab_ui.rx import RESULT -from fastmcp import FastMCP, FastMCPApp - -app = FastMCPApp("Notes") -notes_db: list[dict] = [] - - -@app.tool() -def add_note(title: str, body: str) -> list[dict]: - """Save a note and return all notes.""" - notes_db.append({"title": title, "body": body}) - return list(notes_db) - - -@app.ui() -def notes_app() -> PrefabApp: - """Open the notes app.""" - with Column(gap=6, css_class="p-6") as view: - Heading("Notes") - - with ForEach("notes") as note: - with Row(gap=2, align="center"): - Text(note.title, css_class="font-semibold") - Badge(note.body) - - Separator() - - with Form( - on_submit=CallTool( - "add_note", - on_success=[ - SetState("notes", RESULT), - ShowToast("Note saved!", variant="success"), - ], - on_error=ShowToast("Failed to save", variant="error"), - ) - ): - Input(name="title", label="Title", required=True) - Input(name="body", label="Body", required=True) - Button("Add Note") - - return PrefabApp(view=view, state={"notes": list(notes_db)}) - - -mcp = FastMCP("Notes Server", providers=[app]) -``` - -The model sees one tool: `notes_app`. Calling it opens the UI. When the user submits the form, `CallTool("add_note")` fires, the server saves the note, returns the updated list, and `SetState("notes", RESULT)` writes that list back into state. `ForEach("notes")` re-renders. The model never sees `add_note` — it's UI-only. - -## Why not just `@mcp.tool(app=True)`? - -A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool — there's nothing stopping you from putting `CallTool("add_note")` inside a regular `@mcp.tool(app=True)`. It works for one or two tools. Things get harder once the app grows: - -- Which tools should the model see, and which are UI-only? -- What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`? -- How do you keep it all wired correctly as you compose servers? - -`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers. - -The rest of this page covers each piece in turn. - -## `@app.ui()` — entry points - -Entry points are what the model sees. They return a `PrefabApp` and default to `visibility=["model"]`, showing up in the LLM tool list but not callable from within the UI. - -```python -@app.ui() -def dashboard() -> PrefabApp: - """The model calls this to open the dashboard.""" - with Column(gap=4, css_class="p-6") as view: - Heading("Dashboard") - ... - return PrefabApp(view=view) -``` - -`@app.ui()` supports the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`. - -## `@app.tool()` — backend tools - -Backend tools do the work. By default they're visible only to the UI (`visibility=["app"]`), not the model. - -```python -@app.tool() -def save_contact(name: str, email: str) -> list[dict]: - """Save a contact and return the updated list.""" - db.append({"name": name, "email": email}) - return list(db) -``` - -If you want a tool callable by both the model and the UI, pass `model=True`: - -```python -@app.tool(model=True) -def list_contacts() -> list[dict]: - """Both the model and the UI can call this.""" - return list(db) -``` - -Backend tools support `name`, `description`, `auth`, and `timeout`. - -## `CallTool` — UI → backend - -`CallTool` is how the UI invokes a backend tool. Pass the tool's name (or a direct function reference): - -```python -from prefab_ui.actions.mcp import CallTool - -CallTool("save_contact", arguments={"name": "Alice", "email": "alice@example.com"}) - -# Or a function reference — resolves to a stable global key -CallTool(save_contact, arguments={...}) -``` - -Arguments can reference state with `Rx`: - -```python -from prefab_ui.rx import STATE - -CallTool("search", arguments={"query": STATE.search_term}) -``` - -### Handling results - -Server calls are async. Use `on_success` and `on_error` callbacks: - -```python -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.rx import RESULT - -CallTool( - "save_contact", - on_success=[ - SetState("contacts", RESULT), - ShowToast("Saved!", variant="success"), - ], - on_error=ShowToast("Something went wrong", variant="error"), -) -``` - -`RESULT` is a reactive reference to the tool's return value, available inside `on_success`. `ERROR` (from `prefab_ui.rx`) is the counterpart inside `on_error`. Callbacks can be a single action or a list; they execute in order and short-circuit on error. - -### `result_key` shorthand - -When a tool's return value should replace a state key, use `result_key`: - -```python -CallTool("list_contacts", result_key="contacts") - -# same as: -CallTool("list_contacts", on_success=SetState("contacts", RESULT)) -``` - -## Actions - -`CallTool` is one of several actions. Actions attach to handlers like `on_click`, `on_submit`, and `on_change`. - -Client-side actions run instantly in the browser, no server round-trip: - -```python -from prefab_ui.actions import SetState, ToggleState, AppendState, PopState, ShowToast - -SetState("count", 42) -ToggleState("expanded") -AppendState("items", {"name": "New Item"}) -PopState("items", 0) -ShowToast("Done!", variant="success") -``` - -Pass a list to chain actions: - -```python -Button( - "Reset", - on_click=[ - SetState("query", ""), - SetState("results", []), - ShowToast("Cleared"), - ], -) -``` - -### Loading states - -A common pattern: disable a button and show a spinner while a call is in flight. - -```python -from prefab_ui.rx import Rx - -saving = Rx("saving") - -Button( - saving.then("Saving...", "Save"), - disabled=saving, - on_click=[ - SetState("saving", True), - CallTool( - "save_data", - on_success=[ - SetState("saving", False), - SetState("result", RESULT), - ShowToast("Saved!", variant="success"), - ], - on_error=[ - SetState("saving", False), - ShowToast("Failed", variant="error"), - ], - ), - ], -) - -# PrefabApp(view=view, state={"saving": False, ...}) -``` - -## Forms - -Forms collect input and submit it to a tool. When submitted, named input values become the tool's arguments. - -### Manual forms - -```python -from prefab_ui.components import Form, Input, Select, SelectOption, Textarea, Button - -with Form( - on_submit=CallTool( - "create_ticket", - on_success=ShowToast("Ticket created!", variant="success"), - ) -): - Input(name="title", label="Title", required=True) - with Select(name="priority", label="Priority"): - SelectOption("Low", value="low") - SelectOption("Medium", value="medium") - SelectOption("High", value="high") - Textarea(name="description", label="Description") - Button("Create Ticket") -``` - -On submit, `CallTool` receives `{"title": ..., "priority": ..., "description": ...}`. - -### Forms from Pydantic models - -For structured input, `Form.from_model()` generates the whole form — inputs, labels, validation: - -```python -from typing import Literal -from pydantic import BaseModel, Field - -class BugReport(BaseModel): - title: str = Field(title="Bug Title") - severity: Literal["low", "medium", "high", "critical"] = Field( - title="Severity", default="medium" - ) - description: str = Field(title="Description") - - -@app.ui() -def report_bug() -> PrefabApp: - with Column(gap=4, css_class="p-6") as view: - Heading("Report a Bug") - Form.from_model( - BugReport, - on_submit=CallTool( - "create_bug", - on_success=ShowToast("Bug filed!", variant="success"), - ), - ) - return PrefabApp(view=view) - - -@app.tool() -def create_bug(data: BugReport) -> str: - return f"Created: {data.title}" -``` - -`str` becomes a text input, `Literal` becomes a select, `bool` becomes a checkbox. Field titles and defaults are respected. - -## Composition and namespacing - -The reason `FastMCPApp` exists — and why you'd pick it over plain `@mcp.tool(app=True)` with string-based `CallTool` — is composition safety. - -When you mount a server under a namespace, tool names get prefixed: - -```python -platform = FastMCP("Platform") -platform.mount("contacts", contacts_server) - -# "save_contact" becomes "contacts_save_contact" -``` - -`CallTool("save_contact")` would now be broken. But `CallTool(save_contact)` with a function reference resolves to a globally stable identifier that bypasses the namespace. Your app works the same whether standalone or mounted. - -### Mounting - -`FastMCPApp` is a Provider. Add it to a server with `providers=` or `add_provider`: - -```python -mcp = FastMCP("Platform", providers=[app]) - -# or -mcp = FastMCP("Platform") -mcp.add_provider(app) -``` - -Multiple apps can coexist; each gets its own global keys, so there's no collision even if two apps have a tool named `save`. - -```python -mcp = FastMCP("Platform", providers=[contacts_app, inventory_app, billing_app]) -``` - -### Running standalone - -For development, `FastMCPApp` has a `run()` shortcut that wraps itself in a temporary `FastMCP` server: - -```python -app = FastMCPApp("Contacts") -# ... register tools ... - -if __name__ == "__main__": - app.run() -``` - -## A full example: contact manager - -This brings everything together — entry point, backend tools, Pydantic form, manual form, state, actions, and multi-visibility. - -```python expandable -from __future__ import annotations - -from typing import Literal - -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.actions.mcp import CallTool -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Badge, Button, Column, ForEach, Form, - Heading, Input, Muted, Row, Separator, Text, -) -from prefab_ui.rx import RESULT, Rx -from pydantic import BaseModel, Field -from fastmcp import FastMCP, FastMCPApp - -contacts_db: list[dict] = [ - {"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"}, - {"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"}, -] - - -class ContactModel(BaseModel): - name: str = Field(title="Full Name", min_length=1) - email: str = Field(title="Email") - category: Literal["Customer", "Vendor", "Partner", "Other"] = "Other" - - -app = FastMCPApp("Contacts") - - -@app.tool() -def save_contact(data: ContactModel) -> list[dict]: - """Save a new contact and return the updated list.""" - contacts_db.append(data.model_dump()) - return list(contacts_db) - - -@app.tool() -def search_contacts(query: str) -> list[dict]: - """Filter contacts by name or email.""" - q = query.lower() - return [ - c for c in contacts_db - if q in c["name"].lower() or q in c["email"].lower() - ] - - -@app.tool(model=True) -def list_contacts() -> list[dict]: - """Return all contacts. Visible to both the model and the UI.""" - return list(contacts_db) - - -@app.ui() -def contact_manager() -> PrefabApp: - """Open the contact manager.""" - with Column(gap=6, css_class="p-6") as view: - Heading("Contacts") - - with ForEach("contacts") as contact: - with Row(gap=2, align="center"): - Text(contact.name, css_class="font-medium") - Muted(contact.email) - Badge(contact.category) - - Separator() - - Heading("Add Contact", level=3) - Form.from_model( - ContactModel, - on_submit=CallTool( - "save_contact", - on_success=[ - SetState("contacts", RESULT), - ShowToast("Contact saved!", variant="success"), - ], - on_error=ShowToast("Failed to save", variant="error"), - ), - ) - - Separator() - - Heading("Search", level=3) - with Form( - on_submit=CallTool( - "search_contacts", - arguments={"query": Rx("query")}, - on_success=SetState("contacts", RESULT), - ) - ): - Input(name="query", placeholder="Search by name or email...") - Button("Search") - - return PrefabApp(view=view, state={"contacts": list(contacts_db)}) - - -mcp = FastMCP("Contacts Server", providers=[app]) - -if __name__ == "__main__": - mcp.run() -``` - -Also available as a runnable server at `examples/apps/contacts/contacts_server.py`. - -## Next steps - -- **[Interactive Tools](/apps/prefab)** — the building blocks: charts, tables, dashboards, reactive state -- **[Examples](/apps/examples)** — complete working servers -- **[Development](/apps/development)** — preview and test app tools locally -- **[Prefab UI docs](https://prefab.prefect.io)** — full component reference diff --git a/docs/v3/apps/generative.mdx b/docs/v3/apps/generative.mdx deleted file mode 100644 index b6293d32b..000000000 --- a/docs/v3/apps/generative.mdx +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: Generative UI -sidebarTitle: Generative UI -description: Let the LLM build custom Prefab UIs on the fly. -icon: wand-magic-sparkles -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.2.0" /> - -<video src="/apps/images/generative-ui.mp4" autoPlay loop muted playsInline style={{width:"100%", borderRadius:"8px", marginBottom:"1rem"}} /> - -With Generative UI, the LLM writes the UI code at runtime. Instead of calling a pre-built tool with a fixed shape, the model writes Prefab Python tailored to the current data and request. The user watches the UI stream in as the model generates it. - -```python -from fastmcp import FastMCP -from fastmcp.apps.generative import GenerativeUI - -mcp = FastMCP("Prefab Studio") -mcp.add_provider(GenerativeUI()) -``` - -One provider registers three things: - -- **`generate_prefab_ui`** — a tool that accepts Python code, executes it in a Pyodide sandbox, and renders the result as a Prefab app -- **`search_prefab_components`** — a tool the LLM uses to discover what components are available -- **The streaming renderer** — a `ui://` resource with browser-side Pyodide that progressively renders partial code as the LLM generates it - -## How it works - -When the LLM calls `generate_prefab_ui`, it writes Prefab Python code into the `code` argument. The MCP Apps protocol creates the renderer iframe in parallel with the tool call, so the app is already running by the time partial arguments start flowing. - -As the LLM generates each token: - -1. The host forwards partial arguments to the app via `ontoolinputpartial` -2. The renderer extracts the growing `code` string -3. Browser-side Pyodide executes whatever compiles successfully -4. The user sees components appear as they're written - -When the LLM finishes, the server runs the complete code in a server-side Pyodide sandbox for validation, and the renderer swaps the streaming preview for the final server-validated result. - -## What the LLM writes - -The tool description includes examples that teach the model the Prefab patterns. A typical generation looks like: - -```python -from prefab_ui.components import Column, Row, Heading, Text, Badge, Card, CardContent -from prefab_ui.components.charts import BarChart, ChartSeries -from prefab_ui.app import PrefabApp - -with PrefabApp() as app: - with Column(gap=6, css_class="p-6"): - Heading("Q3 Revenue Report") - - BarChart( - data=[ - {"month": "Jul", "revenue": 42000}, - {"month": "Aug", "revenue": 51000}, - {"month": "Sep", "revenue": 63000}, - ], - series=[ChartSeries(data_key="revenue", label="Revenue")], - x_axis="month", - ) - - with Row(gap=4): - with Card(): - with CardContent(): - Text("Total", css_class="text-sm text-muted-foreground") - Heading("$156,000") - with Card(): - with CardContent(): - Text("Growth", css_class="text-sm text-muted-foreground") - Badge("+18%", variant="success") -``` - -The model writes real Python — loops, f-strings, computation, helper functions. Prefab gives it charts, tables, forms, cards, badges, and layout primitives to compose. - -## The component search tool - -Before writing code, the LLM can call `search_prefab_components` to discover what's available: - -``` -search_prefab_components("Chart") -→ 7 components matching 'Chart': - AreaChart — from prefab_ui.components.charts import AreaChart - BarChart — from prefab_ui.components.charts import BarChart - ... -``` - -Passing `detail=True` returns full field descriptions and docstrings. The search tool introspects Prefab classes at runtime, so it's always up to date with the installed version. - -## Passing data - -The `generate_prefab_ui` tool accepts a `data` parameter. Values become global variables in the sandbox: - -```python -# The LLM can reference 'sales_data' directly in its code -result = await generate_prefab_ui( - code="...", - data={"sales_data": [{"month": "Jan", "revenue": 42000}, ...]} -) -``` - -This lets the model use data from earlier in the conversation to build visualizations. - -## Configuration - -`GenerativeUI` takes options for customizing tool names: - -```python -GenerativeUI( - tool_name="generate_prefab_ui", # default - components_tool_name="search_prefab_components", # default - include_components_tool=True, # default -) -``` - -## Requirements - -Generative UI needs `fastmcp[apps]`, which pulls in `prefab-ui`. The server-side Pyodide sandbox (for final validation) requires Deno — it installs automatically on first use. - -The streaming renderer loads Pyodide from CDN in the browser. The CSP is configured automatically by the provider — no manual setup. - -## Sandbox limitations - -The Pyodide sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, requests, etc.) are **not available** — the LLM's code must work with only built-in Python and Prefab. If the LLM imports something unavailable, the sandbox raises `ImportError`. - -## Next steps - -- **[Interactive Tools](/apps/prefab)** — the component building blocks the LLM will use -- **[Prefab component reference](https://prefab.prefect.io/docs/components)** — full component library -- **[Development](/apps/development)** — preview generative tools locally with `fastmcp dev apps` diff --git a/docs/v3/apps/images/app-approval.png b/docs/v3/apps/images/app-approval.png deleted file mode 100644 index 162f4847f..000000000 Binary files a/docs/v3/apps/images/app-approval.png and /dev/null differ diff --git a/docs/v3/apps/images/app-chart.png b/docs/v3/apps/images/app-chart.png deleted file mode 100644 index cfc816d0e..000000000 Binary files a/docs/v3/apps/images/app-chart.png and /dev/null differ diff --git a/docs/v3/apps/images/app-choice.png b/docs/v3/apps/images/app-choice.png deleted file mode 100644 index 178f6a2b0..000000000 Binary files a/docs/v3/apps/images/app-choice.png and /dev/null differ diff --git a/docs/v3/apps/images/app-contacts.png b/docs/v3/apps/images/app-contacts.png deleted file mode 100644 index 5d74f7cb9..000000000 Binary files a/docs/v3/apps/images/app-contacts.png and /dev/null differ diff --git a/docs/v3/apps/images/app-example-map.png b/docs/v3/apps/images/app-example-map.png deleted file mode 100644 index 5859c59c2..000000000 Binary files a/docs/v3/apps/images/app-example-map.png and /dev/null differ diff --git a/docs/v3/apps/images/app-example-quiz.png b/docs/v3/apps/images/app-example-quiz.png deleted file mode 100644 index b16bcaf43..000000000 Binary files a/docs/v3/apps/images/app-example-quiz.png and /dev/null differ diff --git a/docs/v3/apps/images/app-example-sales-dashboard.png b/docs/v3/apps/images/app-example-sales-dashboard.png deleted file mode 100644 index e0fe709a9..000000000 Binary files a/docs/v3/apps/images/app-example-sales-dashboard.png and /dev/null differ diff --git a/docs/v3/apps/images/app-example-system-dashboard.png b/docs/v3/apps/images/app-example-system-dashboard.png deleted file mode 100644 index 7b85d7ac1..000000000 Binary files a/docs/v3/apps/images/app-example-system-dashboard.png and /dev/null differ diff --git a/docs/v3/apps/images/app-file-upload.png b/docs/v3/apps/images/app-file-upload.png deleted file mode 100644 index 1178c09af..000000000 Binary files a/docs/v3/apps/images/app-file-upload.png and /dev/null differ diff --git a/docs/v3/apps/images/app-form.png b/docs/v3/apps/images/app-form.png deleted file mode 100644 index 30567e37e..000000000 Binary files a/docs/v3/apps/images/app-form.png and /dev/null differ diff --git a/docs/v3/apps/images/app-greet.png b/docs/v3/apps/images/app-greet.png deleted file mode 100644 index 70a0e4412..000000000 Binary files a/docs/v3/apps/images/app-greet.png and /dev/null differ diff --git a/docs/v3/apps/images/app-overview.png b/docs/v3/apps/images/app-overview.png deleted file mode 100644 index 35f68fd58..000000000 Binary files a/docs/v3/apps/images/app-overview.png and /dev/null differ diff --git a/docs/v3/apps/images/app-quickstart-dev-2.png b/docs/v3/apps/images/app-quickstart-dev-2.png deleted file mode 100644 index f04d96d72..000000000 Binary files a/docs/v3/apps/images/app-quickstart-dev-2.png and /dev/null differ diff --git a/docs/v3/apps/images/app-quickstart-dev.png b/docs/v3/apps/images/app-quickstart-dev.png deleted file mode 100644 index d043f0ed3..000000000 Binary files a/docs/v3/apps/images/app-quickstart-dev.png and /dev/null differ diff --git a/docs/v3/apps/images/app-quickstart.png b/docs/v3/apps/images/app-quickstart.png deleted file mode 100644 index ddca745cf..000000000 Binary files a/docs/v3/apps/images/app-quickstart.png and /dev/null differ diff --git a/docs/v3/apps/images/app-showcase.png b/docs/v3/apps/images/app-showcase.png deleted file mode 100644 index c03294bdb..000000000 Binary files a/docs/v3/apps/images/app-showcase.png and /dev/null differ diff --git a/docs/v3/apps/images/dev-app.png b/docs/v3/apps/images/dev-app.png deleted file mode 100644 index fdb05d69e..000000000 Binary files a/docs/v3/apps/images/dev-app.png and /dev/null differ diff --git a/docs/v3/apps/images/generative-ui.mp4 b/docs/v3/apps/images/generative-ui.mp4 deleted file mode 100644 index ca610181e..000000000 Binary files a/docs/v3/apps/images/generative-ui.mp4 and /dev/null differ diff --git a/docs/v3/apps/low-level.mdx b/docs/v3/apps/low-level.mdx deleted file mode 100644 index ccef52b0a..000000000 --- a/docs/v3/apps/low-level.mdx +++ /dev/null @@ -1,304 +0,0 @@ ---- -title: Custom HTML Apps -sidebarTitle: Custom HTML -description: Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly. -icon: code ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Everything on this page is for when you want full control: your own HTML, your own JavaScript framework, a map library, a 3D viewer, custom video playback. [Interactive Tools](/apps/prefab) wrap the MCP Apps extension so you never have to think about it — this page is what you reach for when you need to think about it. - -You'll be working with two things: the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resources and CSP. - -## How it works - -An MCP App has two parts: - -1. A **tool** that does the work and returns data -2. A **`ui://` resource** containing the HTML that renders that data - -The tool declares which resource to use via `AppConfig`. When the host calls the tool, it also fetches the linked resource, renders it in a sandboxed iframe, and pushes the tool result into the app via `postMessage`. The app can also call tools back, enabling interactive workflows. - -```python -import json - -from fastmcp import FastMCP -from fastmcp.apps import AppConfig, ResourceCSP - -mcp = FastMCP("My App Server") - -# The tool does the work -@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) -def generate_chart(data: list[float]) -> str: - return json.dumps({"values": data}) - -# The resource provides the UI -@mcp.resource("ui://my-app/view.html") -def chart_view() -> str: - return "<html>...</html>" -``` - -## AppConfig - -`AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.server.apps`: - -```python -from fastmcp.apps import AppConfig -``` - -On **tools**, you'll typically set `resource_uri` to point to the UI resource: - -```python -@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) -def my_tool() -> str: - return "result" -``` - -You can also pass a raw dict with camelCase keys, matching the wire format: - -```python -@mcp.tool(app={"resourceUri": "ui://my-app/view.html"}) -def my_tool() -> str: - return "result" -``` - -### Tool visibility - -The `visibility` field controls where a tool appears: - -- `["model"]` — visible to the LLM (the default behavior) -- `["app"]` — only callable from within the app UI, hidden from the LLM -- `["model", "app"]` — both - -This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions. - -```python -@mcp.tool( - app=AppConfig( - resource_uri="ui://my-app/view.html", - visibility=["app"], - ) -) -def refresh_data() -> str: - """Only callable from the app UI, not by the LLM.""" - return fetch_latest() -``` - -### AppConfig fields - -| Field | Type | Description | -|-------|------|-------------| -| `resource_uri` | `str` | URI of the UI resource. Tools only. | -| `visibility` | `list[str]` | Where the tool appears: `"model"`, `"app"`, or both. Tools only. | -| `csp` | `ResourceCSP` | Content Security Policy for the iframe. | -| `permissions` | `ResourcePermissions` | Iframe sandbox permissions. | -| `domain` | `str` | Stable sandbox origin for the iframe. | -| `prefers_border` | `bool` | Whether the UI prefers a visible border. | - -<Note> -On **resources**, `resource_uri` and `visibility` must not be set — the resource *is* the UI. Use `AppConfig` on resources only for `csp`, `permissions`, and other display settings. -</Note> - -## UI resources - -Resources using the `ui://` scheme are automatically served with the MIME type `text/html;profile=mcp-app`. No need to set it manually. - -```python -@mcp.resource("ui://my-app/view.html") -def my_view() -> str: - return "<html>...</html>" -``` - -The HTML can be anything — a full single-page app, a simple display, or a complex interactive tool. The host renders it in a sandboxed iframe and establishes a `postMessage` channel for communication. - -### Writing the app HTML - -Your HTML app communicates with the host using the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK. The simplest approach is to load it from a CDN: - -```html -<script type="module"> - import { App } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps"; - - const app = new App({ name: "My App", version: "1.0.0" }); - - // Receive tool results pushed by the host - app.ontoolresult = ({ content }) => { - const text = content?.find(c => c.type === 'text'); - if (text) { - document.getElementById('output').textContent = text.text; - } - }; - - // Connect to the host - await app.connect(); -</script> -``` - -The `App` object provides: - -- **`app.ontoolresult`** — callback that receives tool results pushed by the host -- **`app.callServerTool({name, arguments})`** — call a tool on the server from within the app -- **`app.onhostcontextchanged`** — callback for host context changes (e.g., safe area insets) -- **`app.getHostContext()`** — get current host context - -See the full [ext-apps SDK documentation](https://github.com/modelcontextprotocol/ext-apps) for the complete API reference. - -<Note> -If your HTML loads external scripts, styles, or makes API calls, you need to declare those domains in the CSP configuration. See [Security](#security) below. -</Note> - -## Security - -Apps run in sandboxed iframes with a deny-by-default Content Security Policy. By default, only inline scripts and styles are allowed — no external network access. - -### Content Security Policy - -If your app needs to load external resources (CDN scripts, API calls, embedded iframes), declare the allowed domains with `ResourceCSP`: - -```python -from fastmcp.apps import AppConfig, ResourceCSP - -@mcp.resource( - "ui://my-app/view.html", - app=AppConfig( - csp=ResourceCSP( - resource_domains=["https://unpkg.com", "https://cdn.example.com"], - connect_domains=["https://api.example.com"], - ) - ), -) -def my_view() -> str: - return "<html>...</html>" -``` - -| CSP Field | Controls | -|-----------|----------| -| `connect_domains` | `fetch`, XHR, WebSocket (`connect-src`) | -| `resource_domains` | Scripts, images, styles, fonts (`script-src`, etc.) | -| `frame_domains` | Nested iframes (`frame-src`) | -| `base_uri_domains` | Document base URI (`base-uri`) | - -### Permissions - -If your app needs browser capabilities like camera or clipboard access, request them via `ResourcePermissions`: - -```python -from fastmcp.apps import AppConfig, ResourcePermissions - -@mcp.resource( - "ui://my-app/view.html", - app=AppConfig( - permissions=ResourcePermissions( - camera={}, - clipboard_write={}, - ) - ), -) -def my_view() -> str: - return "<html>...</html>" -``` - -Hosts may or may not grant these permissions. Your app should use JavaScript feature detection as a fallback. - -## Example: a QR code server - -This example creates a tool that generates QR codes and an app that renders them as images. It's based on the [official MCP Apps example](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server). Requires the `qrcode[pil]` package. - -```python expandable -import base64 -import io - -import qrcode -from mcp import types - -from fastmcp import FastMCP -from fastmcp.apps import AppConfig, ResourceCSP -from fastmcp.tools import ToolResult - -mcp = FastMCP("QR Code Server") - -VIEW_URI = "ui://qr-server/view.html" - - -@mcp.tool(app=AppConfig(resource_uri=VIEW_URI)) -def generate_qr(text: str = "https://gofastmcp.com") -> ToolResult: - """Generate a QR code from text.""" - qr = qrcode.QRCode(version=1, box_size=10, border=4) - qr.add_data(text) - qr.make(fit=True) - - img = qr.make_image() - buffer = io.BytesIO() - img.save(buffer, format="PNG") - b64 = base64.b64encode(buffer.getvalue()).decode() - - return ToolResult( - content=[types.ImageContent(type="image", data=b64, mimeType="image/png")] - ) - - -@mcp.resource( - VIEW_URI, - app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), -) -def view() -> str: - """Interactive QR code viewer.""" - return """\ -<!DOCTYPE html> -<html> -<head> - <meta name="color-scheme" content="light dark"> - <style> - body { display: flex; justify-content: center; - align-items: center; height: 340px; width: 340px; - margin: 0; background: transparent; } - img { width: 300px; height: 300px; border-radius: 8px; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); } - </style> -</head> -<body> - <div id="qr"></div> - <script type="module"> - import { App } from - "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps"; - - const app = new App({ name: "QR View", version: "1.0.0" }); - - app.ontoolresult = ({ content }) => { - const img = content?.find(c => c.type === 'image'); - if (img) { - const el = document.createElement('img'); - el.src = `data:${img.mimeType};base64,${img.data}`; - el.alt = "QR Code"; - document.getElementById('qr').replaceChildren(el); - } - }; - - await app.connect(); - </script> -</body> -</html>""" -``` - -The tool generates a QR code as a base64 PNG. The resource loads the MCP Apps JS SDK from unpkg (declared in the CSP), listens for tool results, and renders the image. The host wires them together — when the LLM calls `generate_qr`, the QR code appears in an interactive frame inside the conversation. - -## Checking client support - -Not all hosts support the Apps extension. You can check at runtime using the tool's [context](/servers/context): - -```python -from fastmcp import Context -from fastmcp.apps import AppConfig, UI_EXTENSION_ID - -@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) -async def my_tool(ctx: Context) -> str: - if ctx.client_supports_extension(UI_EXTENSION_ID): - # Return data optimized for UI rendering - return rich_response() - else: - # Fall back to plain text - return plain_text_response() -``` diff --git a/docs/v3/apps/overview.mdx b/docs/v3/apps/overview.mdx deleted file mode 100644 index ff9557058..000000000 --- a/docs/v3/apps/overview.mdx +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: Apps -sidebarTitle: Overview -description: Give your tools interactive UIs rendered directly in the conversation. -icon: grid-2 -mode: center ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' -import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx' -import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx' - -<VersionBadge version="3.0.0" /> - -A FastMCP app is a tool that returns an interactive UI instead of text. When the host calls it, the user sees a chart, a table, a form, or a whole dashboard rendered right inside the conversation, with working sort, search, tooltips, and state. - -<div style={{ - margin: '0 clamp(-180px, calc(-18vw + 90px), 0px) 2rem', - maxHeight: '700px', - overflow: 'hidden', - position: 'relative', - maskImage: 'linear-gradient(to bottom, black 75%, transparent)', - WebkitMaskImage: 'linear-gradient(to bottom, black 75%, transparent)', -}}> - <PrefabDemoFrame demo="hitchhikers" height="2000px" title="Prefab showcase demo" /> -</div> - -The dashboard above is a [Prefab](https://prefab.prefect.io) showcase — a taste of what you can deliver from a FastMCP tool. Every card, chart, slider, dialog, and carousel is a Python component. Build a composition like this, add `@mcp.tool(app=True)`, and the host renders it inside the conversation. - -Under the hood, FastMCP builds on the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) and uses Prefab to describe UIs in Python. - -```bash -pip install "fastmcp[apps]" -``` - -<PrefabPinWarning /> - -## Pick your path - -Four patterns cover almost everything you'd want to build. Most apps start with Interactive Tools; you only reach for the others when you've hit a specific limit. - -### [Interactive Tools](/apps/prefab) — start here - -Add `app=True` to a tool and return a Prefab component. Charts, tables, dashboards, and client-side interactivity (toggles, tabs, filtering) all work without any server round-trips. - -```python -@mcp.tool(app=True) -def team_directory() -> DataTable: - return DataTable(columns=[...], rows=employees, search=True) -``` - -### [FastMCPApp](/apps/fastmcp-app) — when the UI calls back to the server - -Forms that save data, buttons that trigger backend work, search that hits a database. `FastMCPApp` manages the wiring between UI actions and backend tools, with stable tool identifiers that survive server composition. - -### [Generative UI](/apps/generative) — when the LLM writes the UI - -Register one provider and the model can write Prefab code tailored to the current data and request. The user watches the UI build up as the model generates it. - -```python -mcp.add_provider(GenerativeUI()) -``` - -### [Custom HTML](/apps/low-level) — when you need full control - -Write your own HTML, CSS, and JavaScript. Use a specific framework, drop in a map or 3D viewer, embed video. You're talking to the MCP Apps protocol directly. - -## What's next - -- **[Quickstart](/apps/quickstart)** — build a working app in a minute -- **[Examples](/apps/examples)** — complete working servers you can run today -- **[Providers](/apps/providers/approval)** — ready-made capabilities (approvals, choice pickers, file upload, forms) you add with one line -- **[Development](/apps/development)** — preview app tools locally with `fastmcp dev apps` diff --git a/docs/v3/apps/prefab.mdx b/docs/v3/apps/prefab.mdx deleted file mode 100644 index e6ff7070f..000000000 --- a/docs/v3/apps/prefab.mdx +++ /dev/null @@ -1,297 +0,0 @@ ---- -title: Interactive Tools -sidebarTitle: Interactive Tools -description: Turn your tools into interactive UIs with charts, tables, and dashboards. -icon: palette -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' -import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx' -import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx' - -<VersionBadge version="3.1.0" /> - -<PrefabPinWarning /> - -<PrefabDemoFrame demo="dashboard" height="680px" title="Sales dashboard demo" /> - -Believe it or not, that dashboard is a FastMCP tool. The chart has tooltips. The table is sortable. The badges are styled by deal stage. The whole thing is about 40 lines of Python, and the user sees it right inside their conversation instead of a wall of JSON. - -The pattern behind every example on this page is the same: add `app=True` to your tool, build a UI with [Prefab](https://prefab.prefect.io) components, and return it as a `PrefabApp`. Prefab has [100+ components](https://prefab.prefect.io/docs/components), from data tables and charts to forms and progress bars. You compose them in Python; the host renders them as a live, interactive application. - -## Start with a table - -Most tools return data the user wants to explore. A `DataTable` is often the smallest useful upgrade — your data goes from a JSON blob to a searchable, sortable table: - -<PrefabDemoFrame demo="data-table" height="530px" title="Data table demo" /> - -```python -from prefab_ui.components import DataTable, DataTableColumn -from fastmcp import FastMCP - -mcp = FastMCP("Directory") - - -@mcp.tool(app=True) -def team_directory() -> DataTable: - """Browse the team directory.""" - employees = [ - {"name": "Alice Chen", "role": "Staff Engineer", "dept": "Platform"}, - {"name": "Bob Martinez", "role": "Lead Designer", "dept": "Design"}, - {"name": "Carol Johnson", "role": "Senior Engineer", "dept": "Platform"}, - {"name": "David Kim", "role": "Product Manager", "dept": "Product"}, - {"name": "Eva Mueller", "role": "Engineer", "dept": "Platform"}, - {"name": "Frank Lee", "role": "Data Scientist", "dept": "ML"}, - {"name": "Grace Park", "role": "Eng Manager", "dept": "Platform"}, - ] - - return DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="role", header="Role", sortable=True), - DataTableColumn(key="dept", header="Dept", sortable=True), - ], - rows=employees, - search=True, - ) -``` - -That's it. Add `app=True`, return a Prefab component instead of raw dicts. FastMCP handles the rendering, sandboxing, and security. No wrapper class needed for simple cases like this. - -## Add charts - -When numbers tell a better story as a visual, swap in a chart. The API is the same: pass your data as a list of dicts, tell the chart which keys to plot. - -<PrefabDemoFrame demo="bar-chart" height="430px" title="Bar chart demo" /> - -```python -@mcp.tool(app=True) -def quarterly_revenue(year: int) -> BarChart: - """Show quarterly revenue as a bar chart.""" - data = [ - {"quarter": "Q1", "revenue": 42000, "costs": 28000}, - {"quarter": "Q2", "revenue": 51000, "costs": 31000}, - {"quarter": "Q3", "revenue": 47000, "costs": 29000}, - {"quarter": "Q4", "revenue": 63000, "costs": 35000}, - ] - - return BarChart( - data=data, - series=[ - ChartSeries(data_key="revenue", label="Revenue"), - ChartSeries(data_key="costs", label="Costs"), - ], - x_axis="quarter", - show_legend=True, - ) -``` - -Each `ChartSeries` plots a different key from the data. `BarChart`, `LineChart`, `AreaChart`, `PieChart`, `RadarChart`, and `RadialChart` all follow the same pattern. Hover over the bars to see tooltips. - -<PrefabDemoFrame demo="pie-chart" height="410px" title="Pie chart demo" /> - -```python -@mcp.tool(app=True) -def ticket_breakdown() -> PieChart: - """Show open tickets by category.""" - data = [ - {"category": "Bug", "count": 42}, - {"category": "Feature", "count": 28}, - {"category": "Docs", "count": 15}, - {"category": "Infra", "count": 10}, - ] - - return PieChart( - data=data, - data_key="count", - name_key="category", - inner_radius=50, - show_legend=True, - ) -``` - -See the [Prefab chart docs](https://prefab.prefect.io/docs/components) for stacking, curves, custom colors, and more. - -## Compose a dashboard - -Tables and charts are useful on their own, but the real power comes from composing them. `Column` stacks children vertically, `Row` lays them out side by side, and `with` blocks establish nesting — the indentation is the layout. - -<PrefabDemoFrame demo="dashboard" height="680px" title="Sales dashboard demo" /> - -```python expandable -@mcp.tool(app=True) -def sales_dashboard() -> PrefabApp: - """Show sales KPIs, trends, and deals.""" - monthly = [ - {"month": "Jan", "revenue": 48200, "costs": 31000}, - {"month": "Feb", "revenue": 52100, "costs": 32500}, - {"month": "Mar", "revenue": 61800, "costs": 34200}, - {"month": "Apr", "revenue": 58400, "costs": 33800}, - ] - deals = [ - {"account": "Acme Corp", "value": "$84,000", "stage": "Won"}, - {"account": "Globex Inc", "value": "$52,000", "stage": "Negotiation"}, - {"account": "Initech", "value": "$31,500", "stage": "Proposal"}, - {"account": "Wayne Enterprises", "value": "$45,000", "stage": "Lost"}, - ] - - rows = [ - { - "account": d["account"], - "value": d["value"], - "stage": Badge( - d["stage"], - variant="success" if d["stage"] == "Won" - else "destructive" if d["stage"] == "Lost" - else "secondary", - ), - } - for d in deals - ] - - total = sum(m["revenue"] for m in monthly) - - with PrefabApp() as app: - with Column(gap=4, css_class="p-6"): - with Row(gap=6): - Metric(label="Revenue (Q1-Q4)", value=f"${total:,}") - Metric(label="Deals", value=f"{len(deals)}") - BarChart( - data=monthly, - series=[ - ChartSeries(data_key="revenue", label="Revenue"), - ChartSeries(data_key="costs", label="Costs"), - ], - x_axis="month", - show_legend=True, - ) - Separator() - DataTable( - columns=[ - DataTableColumn(key="account", header="Account", sortable=True), - DataTableColumn(key="value", header="Value", sortable=True), - DataTableColumn(key="stage", header="Stage"), - ], - rows=rows, - ) - - return app -``` - -Notice how `Badge` components can be placed inside table cells — any Prefab component works as a cell value, so you can put progress bars, icons, or buttons in your tables too. - -## Make it reactive - -Everything above renders once from the data your Python provides. But interactive tools can also respond to user input in real time, without any server round-trips. Prefab's state system lets components read and write client-side values, so the UI updates instantly as the user interacts with it. - -<PrefabDemoFrame demo="reactive" height="500px" title="Reactive sales demo" /> - -Try switching regions in the dropdown, and toggling the switch on and off. - -```python expandable -from prefab_ui.rx import Rx - -@mcp.tool(app=True) -def regional_sales() -> PrefabApp: - """Sales by region with a live filter.""" - north = [ - {"month": "Jan", "sales": 22000}, - {"month": "Feb", "sales": 25500}, - {"month": "Mar", "sales": 24200}, - ] - south = [ - {"month": "Jan", "sales": 5800}, - {"month": "Feb", "sales": 6400}, - {"month": "Mar", "sales": 5600}, - ] - west = [ - {"month": "Jan", "sales": 6000}, - {"month": "Feb", "sales": 6000}, - {"month": "Mar", "sales": 5600}, - ] - - with PrefabApp( - state={ - "region": "north", - "north": north, "south": south, "west": west, - "show_target": True, - }, - ) as app: - with Column( - gap=4, - css_class="p-6", - let={"data": "{{ region == 'south' ? south" - " : region == 'west' ? west" - " : north }}"}, - ): - with Row(gap=4, align="center"): - with Select(name="region", css_class="w-40"): - SelectOption(value="north", label="North") - SelectOption(value="south", label="South") - SelectOption(value="west", label="West") - Switch(name="show_target", css_class="ml-auto") - Text("Show target", css_class="text-sm text-muted-foreground") - BarChart( - data=Rx("data"), - series=[ChartSeries(data_key="sales", label="Sales")], - x_axis="month", - ) - with If(Rx("show_target")): - Metric(label="Q1 Target", value="$75,000") - - return app -``` - -The `state` dict on `PrefabApp` declares initial values. The `Select` writes to the `region` key on every change. A `let` binding picks the matching dataset, and the chart re-renders. The `Switch` toggles a `Metric` on and off through `If(Rx("show_target"))`. All of this happens in the browser — no calls back to your server. - -`Rx` is a reactive reference: `Rx("region")` compiles to an expression the renderer evaluates live. It supports arithmetic, comparisons, formatting pipes (`.currency()`, `.percent()`), and ternary conditionals (`.then()`). For the full state system, see the [Prefab state docs](https://prefab.prefect.io/docs/concepts/state) and [expression docs](https://prefab.prefect.io/docs/concepts/expressions). - -## Content Security Policy - -Interactive tools render in a sandboxed iframe with a strict CSP. If your tool loads external resources — embedding iframes, fetching from APIs, loading scripts — add the required domains: - -```python -from fastmcp.apps import PrefabAppConfig, ResourceCSP - -@mcp.tool(app=PrefabAppConfig( - csp=ResourceCSP(frame_domains=["https://example.com"]), -)) -def dashboard_with_embed() -> PrefabApp: - ... -``` - -`PrefabAppConfig()` with no arguments is equivalent to `app=True`. - -## Giving the LLM context - -By default, the LLM sees `"[Rendered Prefab UI]"` as the tool result. If the model needs to reason about the data, return a `ToolResult` with a text summary alongside the UI: - -```python -from fastmcp.tools import ToolResult - -@mcp.tool(app=True) -def sales_overview(year: int) -> ToolResult: - """Show sales visually, summarize for the model.""" - data = get_sales_data(year) - total = sum(row["revenue"] for row in data) - - with Column(gap=4, css_class="p-6") as view: - BarChart(data=data, series=[ChartSeries(data_key="revenue")]) - - return ToolResult( - content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters", - structured_content=view, - ) -``` - -The user sees the chart. The model sees the summary. - -## Next steps - -- **[FastMCPApp](/apps/fastmcp-app)** — when your UI needs to call backend tools (forms, search, CRUD) -- **[Generative UI](/apps/generative)** — let the LLM design the UI at runtime -- **[Custom HTML](/apps/low-level)** — when Prefab isn't enough (maps, 3D, your own framework) -- **[Examples](/apps/examples)** — complete working servers you can run today -- **[Development](/apps/development)** — preview your tools locally with `fastmcp dev apps` -- **[Prefab UI](https://prefab.prefect.io)** — full component reference with 100+ components, theming, and advanced patterns diff --git a/docs/v3/apps/providers/approval.mdx b/docs/v3/apps/providers/approval.mdx deleted file mode 100644 index 8ac7b8dd1..000000000 --- a/docs/v3/apps/providers/approval.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Approval -sidebarTitle: Approval -description: Human-in-the-loop approval gates for agent actions -icon: shield-check -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.2.0" /> - -`Approval` adds a human-in-the-loop confirmation step to any server. The LLM presents what it's about to do, the user approves or rejects via buttons, and the decision flows back into the conversation as a message. - -<Frame> - <img src="/apps/images/app-approval.png" alt="The Approval provider shown in Goose, with a payment confirmation card and Approve/Cancel buttons" /> -</Frame> - -```python -from fastmcp import FastMCP -from fastmcp.apps.approval import Approval - -mcp = FastMCP("My Server") -mcp.add_provider(Approval()) -``` - -This registers a single tool: - -| Tool | Visibility | Purpose | -|------|-----------|---------| -| `request_approval` | Model | Shows an approval card, sends the user's decision back as a message | - -The LLM calls `request_approval` with a summary (and optional details) whenever it's about to take a significant action. The user sees a card with Approve and Reject buttons. Clicking either sends a message back into the conversation via `SendMessage`, which triggers the LLM's next turn. - -The message looks like it came from the user: - -``` -"Deploy v3.2 to production" — I selected: Approve -``` - -<Note> -Approval is an advisory gate, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and a determined LLM could proceed without waiting. Think of it as a strong UX signal that encourages confirmation, not a security boundary. For hard enforcement, implement approval logic server-side in your tool implementations. -</Note> - -## Configuration - -The constructor sets defaults; the LLM can override all of these per-call via tool arguments. - -```python -Approval( - name="Approval", # App name - title="Approval Required", # Card heading - approve_text="Approve", # Approve button label - reject_text="Reject", # Reject button label - approve_variant="default", # "default", "destructive", "success", "info" - reject_variant="outline", # same options plus "outline" -) -``` - -The LLM can customize each invocation: - -```python -request_approval( - summary="Delete 47 files from /tmp", - details="This cannot be undone.", - title="Destructive Action", - approve_text="Delete", - approve_variant="destructive", - reject_text="Keep files", -) -``` - -## How it works - -When the user clicks a button, two things happen: - -1. `SendMessage` pushes the decision into the conversation as a user message -2. `SetState("decided", True)` replaces the buttons with "Response sent." - -The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding. If approved, it continues. If rejected, it acknowledges and asks how to proceed. diff --git a/docs/v3/apps/providers/choice.mdx b/docs/v3/apps/providers/choice.mdx deleted file mode 100644 index c29c1b2bc..000000000 --- a/docs/v3/apps/providers/choice.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: Choice -sidebarTitle: Choice -description: Present clickable options instead of free-text responses -icon: list-check -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.2.0" /> - -`Choice` lets the LLM present a set of options as clickable buttons instead of asking the user to type a response. The selection flows back into the conversation as a message, giving the LLM clean structured input. - -<Frame> - <img src="/apps/images/app-choice.png" alt="The Choice provider shown in Goose, with four lunch options as clickable buttons" /> -</Frame> - -```python -from fastmcp import FastMCP -from fastmcp.apps.choice import Choice - -mcp = FastMCP("My Server") -mcp.add_provider(Choice()) -``` - -This registers a single tool: - -| Tool | Visibility | Purpose | -|------|-----------|---------| -| `choose` | Model | Shows a card with clickable options, sends the selection back as a message | - -The LLM calls `choose` with a prompt and a list of options. The user sees a card with one button per option. Clicking one sends a message back into the conversation: - -``` -"Which deployment strategy?" — I selected: Blue-green -``` - -<Note> -This is an advisory interaction, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and the LLM could proceed without waiting. The tool description instructs the LLM to stop and wait for the "I selected:" response, but for hard enforcement, implement selection logic server-side. -</Note> - -## Configuration - -The constructor sets defaults; the LLM can override `title` per-call. - -```python -Choice( - name="Choice", # App name - title="Choose an Option", # Default card heading - variant="outline", # Button style for all options -) -``` - -The LLM provides the options per-call: - -```python -choose( - prompt="What should we have for lunch?", - options=["Pizza", "Tacos", "Ramen", "Salad"], - title="The Important Questions", -) -``` - -## How it works - -Each option renders as a full-width button in a vertical stack. When the user clicks one: - -1. `SendMessage` pushes the selection into the conversation as a user message -2. `SetState("decided", True)` replaces the buttons with "Response sent." - -The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding with whatever the user chose. diff --git a/docs/v3/apps/providers/file-upload.mdx b/docs/v3/apps/providers/file-upload.mdx deleted file mode 100644 index b9709d946..000000000 --- a/docs/v3/apps/providers/file-upload.mdx +++ /dev/null @@ -1,129 +0,0 @@ ---- -title: File Upload -sidebarTitle: File Upload -description: Drag-and-drop file upload for any MCP server -icon: upload -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.2.0" /> - -`FileUpload` adds drag-and-drop file upload to any server. Users upload files through an interactive UI, bypassing the LLM context window entirely. The LLM can then list and read uploaded files through model-visible tools. - -<Frame> - <img src="/apps/images/app-file-upload.png" alt="The FileUpload provider shown in Goose, with a drag-and-drop zone for uploading files" /> -</Frame> - -```python -from fastmcp import FastMCP -from fastmcp.apps.file_upload import FileUpload - -mcp = FastMCP("My Server") -mcp.add_provider(FileUpload()) -``` - -This registers four tools: - -| Tool | Visibility | Purpose | -|------|-----------|---------| -| `file_manager` | Model | Opens the drag-and-drop upload UI | -| `store_files` | App only | Called by the UI when the user clicks Upload | -| `list_files` | Model | Returns metadata for all uploaded files | -| `read_file` | Model | Returns a file's contents by name | - -The LLM sees `file_manager`, `list_files`, and `read_file`. It calls `file_manager` to show the upload interface, then uses `list_files` and `read_file` to work with whatever the user uploaded. `store_files` is app-only — the UI calls it directly and the LLM never needs to know about it. - -## Configuration - -```python -FileUpload( - name="Files", # App name (used in tool routing) - max_file_size=10 * 1024 * 1024, # 10 MB default, enforced server-side - title="File Upload", # Heading shown in the UI - description="Drop files to...", # Description text below the heading - drop_label="Drop files here", # Label inside the drop zone -) -``` - -The `max_file_size` limit is enforced both in the UI (the DropZone rejects oversized files) and on the server (the `store_files` tool validates before calling `on_store`). - -## Storage scoping - -By default, files are stored in memory and scoped by MCP session ID. Each session gets its own isolated file store — files uploaded in one conversation aren't visible in another. - -This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sessions persist across requests. - -<Warning> -In **stateless HTTP** mode, each request creates a new session object with a new ID. Files stored during one request (e.g. the UI upload) will be invisible to the next request (e.g. the LLM calling `list_files`). You **must** override `_get_scope_key` to use a stable identifier like a user ID from your auth token. -</Warning> - -For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user: - -```python -from fastmcp.apps.file_upload import FileUpload - -class UserScopedUpload(FileUpload): - def _get_scope_key(self, ctx): - return ctx.access_token["sub"] -``` - -For process-wide shared storage (all users see all files): - -```python -class SharedUpload(FileUpload): - def _get_scope_key(self, ctx): - return "__shared__" -``` - -## Custom storage - -The default implementation stores files in memory for the lifetime of the server process. For persistent storage, subclass `FileUpload` and override three methods. Each receives the current `Context`, giving you access to session IDs, auth tokens, and request metadata for partitioning and authorization. - -```python -import base64 - -from fastmcp.apps.file_upload import FileUpload - -class S3Upload(FileUpload): - def on_store(self, files, ctx): - user_id = ctx.access_token["sub"] - for f in files: - s3.put_object( - Bucket="uploads", - Key=f"{user_id}/{f['name']}", - Body=base64.b64decode(f["data"]), - ) - return self.on_list(ctx) - - def on_list(self, ctx): - user_id = ctx.access_token["sub"] - objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/") - return [ - { - "name": obj["Key"].split("/", 1)[1], - "type": "application/octet-stream", - "size": obj["Size"], - "size_display": f"{obj['Size']} B", - "uploaded_at": obj["LastModified"].isoformat(), - } - for obj in objects.get("Contents", []) - ] - - def on_read(self, name, ctx): - user_id = ctx.access_token["sub"] - obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}") - content = obj["Body"].read() - return { - "name": name, - "size": obj["ContentLength"], - "type": obj["ContentType"], - "uploaded_at": obj["LastModified"].isoformat(), - "content": content.decode("utf-8"), - } -``` - -Each file dict passed to `on_store` contains `name`, `size`, `type`, and `data` (base64-encoded content). The return value from `on_store` and `on_list` should be a list of summary dicts with `name`, `type`, `size`, `size_display`, and `uploaded_at` fields — these populate the file list in the UI. - -`on_read` returns a dict with file metadata and either `content` (decoded text) or `content_base64` (a base64 preview for binary files). diff --git a/docs/v3/apps/providers/form.mdx b/docs/v3/apps/providers/form.mdx deleted file mode 100644 index e61dc0ce0..000000000 --- a/docs/v3/apps/providers/form.mdx +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Form Input -sidebarTitle: Form Input -description: Collect structured data from users via Pydantic models -icon: rectangle-list -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.2.0" /> - -`FormInput` generates a validated form from a Pydantic model. The user fills it out, and the submission is validated against the model before being returned. Structured elicitation that can't be hallucinated. - -<Frame> - <img src="/apps/images/app-form.png" alt="The FormInput provider shown in Goose, with a bug report form" /> -</Frame> - -```python -from typing import Literal - -from pydantic import BaseModel, Field -from fastmcp import FastMCP -from fastmcp.apps.form import FormInput - -class BugReport(BaseModel): - title: str = Field(description="Brief summary") - severity: Literal["low", "medium", "high", "critical"] - description: str = Field( - description="Detailed description", - json_schema_extra={"ui": {"type": "textarea"}}, - ) - -mcp = FastMCP("My Server") -mcp.add_provider(FormInput(model=BugReport)) -``` - -This registers two tools: - -| Tool | Visibility | Purpose | -|------|-----------|---------| -| `collect_bugreport` | Model | Opens the form UI | -| `submit_form` | App only | Validates and processes the submission | - -The tool name is derived from the model class name, lowercased: `collect_{modelname}`. So `BugReport` becomes `collect_bugreport`, `ShippingAddress` becomes `collect_shippingaddress`. Use `tool_name` to override if needed. The LLM calls it with a prompt explaining what it needs, and the user gets a form with fields matching the model. - -## Field mapping - -`FormInput` uses Prefab's `Form.from_model()`, which maps Pydantic types to form components: - -| Python type | Form component | -|------------|---------------| -| `str` | Text input | -| `int`, `float` | Number input | -| `bool` | Checkbox | -| `datetime.date` | Date picker | -| `Literal[...]` | Select dropdown | -| `SecretStr` | Password input | - -Use `Field()` metadata to control labels (`title`), placeholders (`description`), and validation (`min_length`, `max_length`, `ge`, `le`). Use `json_schema_extra={"ui": {"type": "textarea"}}` for multiline text. - -## Callback - -By default, the validated model is returned as JSON. Provide an `on_submit` callback to process the data server-side: - -```python -def save_report(report: BugReport) -> str: - db.insert(report.model_dump()) - return f"Bug #{db.last_id} filed: {report.title}" - -mcp.add_provider(FormInput(model=BugReport, on_submit=save_report)) -``` - -The callback receives a validated model instance and returns a string that becomes the tool result. - -## Configuration - -```python -FormInput( - model=BugReport, # Required: the Pydantic model - name="BugTracker", # App name (default: model name) - title="File a Bug", # Card heading (default: model name) - tool_name="file_bug", # Tool name (default: collect_{model}) - submit_text="Submit Report", # Button label (default: "Submit") - on_submit=save_report, # Optional callback - send_message=True, # Push result as a chat message -) -``` - -Set `send_message=True` to push the result back into the conversation via `SendMessage`, triggering the LLM's next turn. Without it, the result is just the tool return value. - -## Multiple forms - -Add multiple providers for different models — each gets its own tool: - -```python -mcp = FastMCP( - "My Server", - providers=[ - FormInput(model=ShippingAddress), - FormInput(model=BugReport), - FormInput(model=ContactInfo), - ], -) -``` diff --git a/docs/v3/apps/quickstart.mdx b/docs/v3/apps/quickstart.mdx deleted file mode 100644 index 2221b9de9..000000000 --- a/docs/v3/apps/quickstart.mdx +++ /dev/null @@ -1,197 +0,0 @@ ---- -title: Quickstart -sidebarTitle: Quickstart -description: Build your first FastMCP app in under a minute. -icon: rocket -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' -import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx' - -<VersionBadge version="3.2.0" /> - -By the end of this page, you'll have a working tool that returns this: - -<PrefabDemoFrame demo="team-directory" height="545px" title="Team directory demo" /> - -A pie chart the user can hover, a table they can sort and search — and a single Python tool. - -## Install - -```bash -pip install "fastmcp[apps]" -``` - -The `apps` extra pulls in [Prefab](https://prefab.prefect.io), the Python component library used to build app UIs. - -## Write the tool - -Create `server.py`. The interesting parts: `app=True` tells FastMCP this tool renders a UI, and `with PrefabApp() as app:` is the canonical pattern for composing one. - -```python server.py expandable -from collections import Counter - -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, DataTable, DataTableColumn, Grid -from prefab_ui.components.charts import PieChart -from fastmcp import FastMCP - -mcp = FastMCP("My First App") - - -@mcp.tool(app=True) -def team_directory() -> PrefabApp: - """Browse the team directory.""" - members = [ - {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco"}, - {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York"}, - {"name": "Carol Johnson", "role": "Senior Engineer", "office": "London"}, - {"name": "David Kim", "role": "Product Manager", "office": "San Francisco"}, - {"name": "Eva Mueller", "role": "Engineer", "office": "Berlin"}, - {"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco"}, - {"name": "Grace Park", "role": "Engineering Manager", "office": "New York"}, - ] - - office_counts = [ - {"office": office, "count": count} - for office, count in Counter(m["office"] for m in members).items() - ] - - with PrefabApp() as app: - with Column(gap=4, css_class="p-6"): - with Grid(columns=[1, 2], gap=4): - PieChart( - data=office_counts, - data_key="count", - name_key="office", - show_legend=True, - ) - DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="role", header="Role", sortable=True), - DataTableColumn(key="office", header="Office", sortable=True), - ], - rows=members, - search=True, - ) - - return app -``` - -The Prefab code reads top-to-bottom. `PrefabApp()` is the root; everything inside its `with` block becomes the UI. `Column` stacks children vertically, `Grid` lays them out in columns. `DataTable` takes rows and column definitions and gives you sort and search for free. - -`app=True` does the rest: it sets up the renderer resource, the content security policy, and the metadata that tells the host "this tool returns a UI." The host loads the result in a sandboxed iframe where the user can interact with it — all client-side, no round-trips. - -## Preview it - -FastMCP ships a dev server that renders your app tools in a browser, no MCP host needed: - -```bash -fastmcp dev apps server.py -``` - -Open `http://localhost:8080`, pick `team_directory`, and try sorting columns and searching. - -<Frame> - <img src="/apps/images/app-quickstart-dev-2.png" alt="The team directory rendered in the fastmcp dev apps preview, showing a pie chart, searchable table, and a detail card after clicking a row" /> -</Frame> - -## Make it reactive - -The UI above renders once from your Python. Prefab apps can also respond to user input live, without any server round-trips. The key concept is **state**: a client-side key-value store that components read from and write to. - -Click a row in the demo below to see a detail card appear: - -<PrefabDemoFrame demo="team-directory-reactive" height="675px" title="Reactive team directory demo" /> - -Add a few imports, give each member a couple more fields, wire up a click handler, and render a detail card when something's selected: - -```python expandable server.py -from collections import Counter - -from prefab_ui.actions import SetState -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Badge, Card, CardContent, CardHeader, Column, DataTable, DataTableColumn, - Grid, H3, Row, Small, Text, -) -from prefab_ui.components.charts import PieChart -from prefab_ui.components.control_flow import If -from prefab_ui.rx import Rx, STATE -from fastmcp import FastMCP - -mcp = FastMCP("My First App") - -MEMBERS = [ - {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco", "email": "alice@company.com", "projects": 3}, - {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York", "email": "bob@company.com", "projects": 5}, - # ... more members ... -] - -OFFICE_COUNTS = [ - {"office": o, "count": c} - for o, c in Counter(m["office"] for m in MEMBERS).items() -] - - -@mcp.tool(app=True) -def team_directory() -> PrefabApp: - """Browse the team directory.""" - with PrefabApp(state={"selected": None}) as app: - with Column(gap=4, css_class="p-6"): - with Grid(columns=[1, 2], gap=4): - PieChart( - data=OFFICE_COUNTS, - data_key="count", - name_key="office", - show_legend=True, - ) - DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="role", header="Role", sortable=True), - DataTableColumn(key="office", header="Office", sortable=True), - ], - rows=MEMBERS, - search=True, - on_row_click=SetState("selected", Rx("$event")), - ) - - with If(STATE.selected): - with Card(): - with CardHeader(): - with Row(gap=2, align="center"): - H3(Rx("selected.name")) - Badge(Rx("selected.office")) - with CardContent(): - with Grid(columns=3, gap=4): - with Column(gap=0): - Small("Role") - Text(Rx("selected.role")) - with Column(gap=0): - Small("Email") - Text(Rx("selected.email")) - with Column(gap=0): - Small("Active Projects") - Text(Rx("selected.projects")) - - return app -``` - -Three new ideas do all the work: - -- **`on_row_click=SetState("selected", Rx("$event"))`** — clicking a row writes its data into the `selected` state key. `$event` is the clicked row dict. -- **`Rx("selected.name")`** — a reactive reference. It doesn't hold a Python value; it compiles to a browser-side expression that re-evaluates whenever `selected` changes, so `Text(Rx("selected.name"))` always shows the latest clicked name. -- **`If(STATE.selected)`** — conditionally renders its body. Before any click, `selected` is `None` and the card stays hidden. - -The `state={"selected": None}` dict on `PrefabApp` sets the initial value. Everything else happens in the browser — no round-trips to your server when the user clicks. - -## Where to go next - -You've built a tool that returns an interactive, reactive UI. This pattern covers a huge range of use cases: build a visualization, return it, and the user gets it rendered right in the conversation. - -- **[Interactive Tools](/apps/prefab)** — charts, tables, dashboards, reactive state, with live demos -- **[FastMCPApp](/apps/fastmcp-app)** — when the UI needs to call back to your server (forms, search, CRUD) -- **[Examples](/apps/examples)** — complete working servers you can run today diff --git a/docs/v3/changelog.mdx b/docs/v3/changelog.mdx deleted file mode 100644 index 9ee438d53..000000000 --- a/docs/v3/changelog.mdx +++ /dev/null @@ -1,3759 +0,0 @@ ---- -title: "Changelog" -icon: "list-check" -rss: true -tag: NEW ---- - -<Update label="v3.4.4" description="2026-07-08"> - -**[v3.4.4: Host in Translation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.4)** - -FastMCP 3.4.4 restores HTTP deployment compatibility after the 3.4.3 Host/Origin guard changed default behavior for existing ASGI, serverless, and reverse-proxy deployments. The guard implementation remains available for deployments that opt in with explicit trusted hosts and origins, while 3.x returns to accepting traffic that worked before the patch. This release also adds Hugging Face OAuth provider support, with docs and examples for public and private apps, PKCE, Dynamic Client Registration, and CIMD. - -### Enhancements ✨ -* Hugging Face Auth Integration by [@evalstate](https://github.com/evalstate) in [#4385](https://github.com/PrefectHQ/fastmcp/pull/4385) -### Fixes 🐞 -* Relax host origin guard defaults by [@jlowin](https://github.com/jlowin) in [#4439](https://github.com/PrefectHQ/fastmcp/pull/4439) -* Restore HTTP host guard compatibility by [@jlowin](https://github.com/jlowin) in [#4472](https://github.com/PrefectHQ/fastmcp/pull/4472) - -## New Contributors -* @evalstate made their first contribution in [#4385](https://github.com/PrefectHQ/fastmcp/pull/4385) - -**Full Changelog**: [v3.4.3...v3.4.4](https://github.com/PrefectHQ/fastmcp/compare/v3.4.3...v3.4.4) - -</Update> - -<Update label="v3.4.3" description="2026-07-05"> - -**[v3.4.3: The Fast and the Secure-ious](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.3)** - -FastMCP 3.4.3 closes out a month of SSRF and OAuth hardening: NAT64, 6to4, Teredo, and ISATAP transition addresses can no longer smuggle private IPv4 targets past the SSRF allow-list, Streamable HTTP now validates Host and Origin before session handling to block DNS rebinding against localhost-bound servers, and OAuth redirect validation rejects unsafe schemes and unregistered DCR redirect URIs. Alongside the security work, this release also fixes proxy session teardown races, discriminator-tag handling in JSON schema conversion, and several smaller reliability issues. - -### Enhancements ✨ -* Dedupe discriminator-required helper across schema converters by [@jlowin](https://github.com/jlowin) in [#4362](https://github.com/PrefectHQ/fastmcp/pull/4362) -* Add real Monty sandbox e2e coverage for CodeMode call_tool by [@AlexlaGuardia](https://github.com/AlexlaGuardia) in [#4274](https://github.com/PrefectHQ/fastmcp/pull/4274) -* Switch prettier hook to rbubley/mirrors-prettier by [@jlowin](https://github.com/jlowin) in [#4366](https://github.com/PrefectHQ/fastmcp/pull/4366) -* feat(remote): add --verify flag for TLS certificate verification by [@jlowin](https://github.com/jlowin) in [#4369](https://github.com/PrefectHQ/fastmcp/pull/4369) -### Security 🔒 -* fix(deps): clear Dependabot security alerts via lockfile bumps by [@jlowin](https://github.com/jlowin) in [#4393](https://github.com/PrefectHQ/fastmcp/pull/4393) -* Clarify resource path parameter safety by [@jlowin](https://github.com/jlowin) in [#4398](https://github.com/PrefectHQ/fastmcp/pull/4398) -* Fix dev apps launch escaping by [@jlowin](https://github.com/jlowin) in [#4399](https://github.com/PrefectHQ/fastmcp/pull/4399) -* Block NAT64 SSRF bypass by [@jlowin](https://github.com/jlowin) in [#4400](https://github.com/PrefectHQ/fastmcp/pull/4400) -* [codex] Fix event store replay isolation by [@jlowin](https://github.com/jlowin) in [#4402](https://github.com/PrefectHQ/fastmcp/pull/4402) -* Fix DCR redirect URI validation by [@jlowin](https://github.com/jlowin) in [#4408](https://github.com/PrefectHQ/fastmcp/pull/4408) -* Protect streamable HTTP from DNS rebinding by [@jlowin](https://github.com/jlowin) in [#4405](https://github.com/PrefectHQ/fastmcp/pull/4405) -* Block unsafe OAuth redirect schemes by [@jlowin](https://github.com/jlowin) in [#4419](https://github.com/PrefectHQ/fastmcp/pull/4419) -* Block IPv6 transition SSRF bypasses by [@jlowin](https://github.com/jlowin) in [#4426](https://github.com/PrefectHQ/fastmcp/pull/4426) -### Fixes 🐞 -* fix: caching middleware TypeError on cache miss due to mismatched call_next parameter by [@gmenziesint](https://github.com/gmenziesint) in [#4301](https://github.com/PrefectHQ/fastmcp/pull/4301) -* Fix: async rate limiting middleware get_client_id callbacks by [@Chotom](https://github.com/Chotom) in [#4319](https://github.com/PrefectHQ/fastmcp/pull/4319) -* Recognize all GitHub issue-link forms in require-issue-link workflow by [@jlowin](https://github.com/jlowin) in [#4359](https://github.com/PrefectHQ/fastmcp/pull/4359) -* fix: preserve required discriminator tags by [@he-yufeng](https://github.com/he-yufeng) in [#4297](https://github.com/PrefectHQ/fastmcp/pull/4297) -* fix(proxy): shield stateful proxy disconnect during session teardown by [@jlowin](https://github.com/jlowin) in [#4363](https://github.com/PrefectHQ/fastmcp/pull/4363) -* fix(fs): isolate same-named package imports across providers by [@jlowin](https://github.com/jlowin) in [#4361](https://github.com/PrefectHQ/fastmcp/pull/4361) -* fix: StatefulProxyClient.clear() no longer causes KeyError on session teardown by [@tcconnally](https://github.com/tcconnally) in [#4328](https://github.com/PrefectHQ/fastmcp/pull/4328) -* fix: guard recursive refs in json_schema_to_type by [@Epochex](https://github.com/Epochex) in [#4312](https://github.com/PrefectHQ/fastmcp/pull/4312) -* Forward IdP auth errors to MCP client instead of showing HTML error page by [@bobbyjames839](https://github.com/bobbyjames839) in [#4293](https://github.com/PrefectHQ/fastmcp/pull/4293) -* fix(resources): round-trip path values with reserved characters in URI templates by [@jlowin](https://github.com/jlowin) in [#4368](https://github.com/PrefectHQ/fastmcp/pull/4368) -* fix: bracket IPv6 hosts in server startup log URL by [@jlowin](https://github.com/jlowin) in [#4372](https://github.com/PrefectHQ/fastmcp/pull/4372) -* fix: bound default OIDC discovery timeout and expose it on provider wrappers by [@jlowin](https://github.com/jlowin) in [#4374](https://github.com/PrefectHQ/fastmcp/pull/4374) -* fix: validate task tool arguments against declared types by [@jlowin](https://github.com/jlowin) in [#4373](https://github.com/PrefectHQ/fastmcp/pull/4373) -* fix(tools): honor serialize_by_alias in tool result serialization by [@jlowin](https://github.com/jlowin) in [#4391](https://github.com/PrefectHQ/fastmcp/pull/4391) -* Fix/cimd flow issue by [@twjackysu](https://github.com/twjackysu) in [#4206](https://github.com/PrefectHQ/fastmcp/pull/4206) -* Reject empty env var keys by [@CodingFeng101](https://github.com/CodingFeng101) in [#4410](https://github.com/PrefectHQ/fastmcp/pull/4410) -* fix: correct replace_type docstring parameter descriptions by [@hiSandog](https://github.com/hiSandog) in [#4375](https://github.com/PrefectHQ/fastmcp/pull/4375) -* Fix ty 0.0.55 diagnostics and prefab-ui protocol version drift by [@jlowin](https://github.com/jlowin) in [#4428](https://github.com/PrefectHQ/fastmcp/pull/4428) -* [codex] Fix OpenAPI resource template requests by [@jlowin](https://github.com/jlowin) in [#4407](https://github.com/PrefectHQ/fastmcp/pull/4407) -### Docs 📚 -* fix: RST docstrings in fastmcp.types render raw on gofastmcp.com by [@jlowin](https://github.com/jlowin) in [#4367](https://github.com/PrefectHQ/fastmcp/pull/4367) -* docs: fix 5 broken internal links (auth & providers pages) by [@Michael-WhiteCapData](https://github.com/Michael-WhiteCapData) in [#4344](https://github.com/PrefectHQ/fastmcp/pull/4344) -* docs: add audit/event-record recipe for tool-call middleware by [@AlexlaGuardia](https://github.com/AlexlaGuardia) in [#4345](https://github.com/PrefectHQ/fastmcp/pull/4345) -### Dependencies 📦 -* chore(deps): bump actions/checkout from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4343](https://github.com/PrefectHQ/fastmcp/pull/4343) -* chore(deps): bump joserfc from 1.6.5 to 1.6.7 in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4394](https://github.com/PrefectHQ/fastmcp/pull/4394) -* chore(deps): bump joserfc from 1.6.7 to 1.6.8 in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4429](https://github.com/PrefectHQ/fastmcp/pull/4429) -### Other Changes 🦾 -* Raise fastmcp.ValidationError for invalid tool arguments by [@jlowin](https://github.com/jlowin) in [#4392](https://github.com/PrefectHQ/fastmcp/pull/4392) -* Fix versioned auth middleware checks by [@jlowin](https://github.com/jlowin) in [#4401](https://github.com/PrefectHQ/fastmcp/pull/4401) - -## New Contributors -* @gmenziesint made their first contribution in [#4301](https://github.com/PrefectHQ/fastmcp/pull/4301) -* @Chotom made their first contribution in [#4319](https://github.com/PrefectHQ/fastmcp/pull/4319) -* @he-yufeng made their first contribution in [#4297](https://github.com/PrefectHQ/fastmcp/pull/4297) -* @AlexlaGuardia made their first contribution in [#4274](https://github.com/PrefectHQ/fastmcp/pull/4274) -* @tcconnally made their first contribution in [#4328](https://github.com/PrefectHQ/fastmcp/pull/4328) -* @Epochex made their first contribution in [#4312](https://github.com/PrefectHQ/fastmcp/pull/4312) -* @Michael-WhiteCapData made their first contribution in [#4344](https://github.com/PrefectHQ/fastmcp/pull/4344) -* @bobbyjames839 made their first contribution in [#4293](https://github.com/PrefectHQ/fastmcp/pull/4293) -* @twjackysu made their first contribution in [#4206](https://github.com/PrefectHQ/fastmcp/pull/4206) -* @CodingFeng101 made their first contribution in [#4410](https://github.com/PrefectHQ/fastmcp/pull/4410) -* @hiSandog made their first contribution in [#4375](https://github.com/PrefectHQ/fastmcp/pull/4375) - -**Full Changelog**: [v3.4.2...v3.4.3](https://github.com/PrefectHQ/fastmcp/compare/v3.4.2...v3.4.3) - -</Update> - -<Update label="v3.4.2" description="2026-06-06"> - -**[v3.4.2: Heads Up](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.2)** - -FastMCP 3.4.2 restores JWT compatibility for providers that include private, non-critical JWS header parameters. Tokens from providers like Clerk can carry header metadata such as `cat` without being rejected before signature and claim validation, while unsupported critical headers are still rejected. - -### Fixes 🐞 -* Allow private JWT headers by [@jlowin](https://github.com/jlowin) in [#4290](https://github.com/PrefectHQ/fastmcp/pull/4290) -### Docs 📚 -* Docs: add v3.4.1 changelog entries by [@jlowin](https://github.com/jlowin) in [#4289](https://github.com/PrefectHQ/fastmcp/pull/4289) - -**Full Changelog**: [v3.4.1...v3.4.2](https://github.com/PrefectHQ/fastmcp/compare/v3.4.1...v3.4.2) - -</Update> - -<Update label="v3.4.1" description="2026-06-05"> - -**[v3.4.1: Floor It](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.1)** - -FastMCP 3.4.1 floors Starlette at `>=1.0.1` so installs can no longer resolve to a version affected by CVE-2026-48710, which was previously only constrained transitively through `mcp`. It also makes OAuthProxy log refresh-token cache misses instead of failing silently. - -### Enhancements ✨ -* Log refresh-token misses in OAuthProxy instead of failing silently by [@jlowin](https://github.com/jlowin) in [#4276](https://github.com/PrefectHQ/fastmcp/pull/4276) -### Security 🔒 -* Add explicit starlette>=1.0.1 floor (CVE-2026-48710) by [@jlowin](https://github.com/jlowin) in [#4286](https://github.com/PrefectHQ/fastmcp/pull/4286) -### Docs 📚 -* Document --notes-start-tag in release instructions by [@jlowin](https://github.com/jlowin) in [#4275](https://github.com/PrefectHQ/fastmcp/pull/4275) - -**Full Changelog**: [v3.4.0...v3.4.1](https://github.com/PrefectHQ/fastmcp/compare/v3.4.0...v3.4.1) - -</Update> - -<Update label="v3.4.0" description="2026-06-02"> - -**[v3.4.0: Remote Control](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.0)** - -FastMCP 3.4 is about reaching servers that live somewhere else. The headline is `fastmcp-remote`, a standalone bridge that connects stdio-only MCP hosts to servers hosted over HTTP. Around it, the proxy layer those connections depend on is hardened: a proxy now forwards `initialize` upstream and fails loudly when the backend is missing or misconfigured, instead of reporting a connected-but-empty proxy. And FastMCP-issued access tokens can now outlive short-lived upstream tokens, so authenticated sessions survive the long idle periods remote clients are prone to. - -### New Features 🎉 -* Add fastmcp-remote bridge package by [@jlowin](https://github.com/jlowin) in [#4208](https://github.com/PrefectHQ/fastmcp/pull/4208) -### Breaking Changes ⚠️ -* Forward proxy initialize as bridge behavior by [@jlowin](https://github.com/jlowin) in [#4228](https://github.com/PrefectHQ/fastmcp/pull/4228) -### Enhancements ✨ -* ci: require external PRs to link a tracked issue by [@strawgate](https://github.com/strawgate) in [#4173](https://github.com/PrefectHQ/fastmcp/pull/4173) -* feat: new options --host and --no-log-panel | --log-panel to cli dev apps by [@itaru2622](https://github.com/itaru2622) in [#4123](https://github.com/PrefectHQ/fastmcp/pull/4123) -* Add valid_scopes and extra_authorize_params to WorkOSProvider by [@tiagoskaneta](https://github.com/tiagoskaneta) in [#4135](https://github.com/PrefectHQ/fastmcp/pull/4135) -* Add token_expiry_threshold_seconds for proactive token refresh by [@mohankumarelec](https://github.com/mohankumarelec) in [#4142](https://github.com/PrefectHQ/fastmcp/pull/4142) -* Add review-issue skill for triaging gated external contributions by [@jlowin](https://github.com/jlowin) in [#4212](https://github.com/PrefectHQ/fastmcp/pull/4212) -* Add contract gate to review-issue skill by [@jlowin](https://github.com/jlowin) in [#4214](https://github.com/PrefectHQ/fastmcp/pull/4214) -* Let ToolResult return an error result via is_error by [@jlowin](https://github.com/jlowin) in [#4217](https://github.com/PrefectHQ/fastmcp/pull/4217) -* Update published docs after PyPI release by [@jlowin](https://github.com/jlowin) in [#4211](https://github.com/PrefectHQ/fastmcp/pull/4211) -* Allow pre-bound HTTP sockets by [@jlowin](https://github.com/jlowin) in [#4222](https://github.com/PrefectHQ/fastmcp/pull/4222) -* Add targeted coverage tests by [@strawgate](https://github.com/strawgate) in [#4230](https://github.com/PrefectHQ/fastmcp/pull/4230) -* Upgrade ty to 0.0.39 by [@jlowin](https://github.com/jlowin) in [#4225](https://github.com/PrefectHQ/fastmcp/pull/4225) -* Decouple FastMCP access token lifetime from upstream expires_in by [@jlowin](https://github.com/jlowin) in [#4254](https://github.com/PrefectHQ/fastmcp/pull/4254) -### Security 🔒 -* feat(code-mode): default sandbox limits and per-execution tool-call cap by [@strawgate](https://github.com/strawgate) in [#4170](https://github.com/PrefectHQ/fastmcp/pull/4170) -* Security: Fix 3 findings in GitHub Actions workflows by [@jpr5](https://github.com/jpr5) in [#4183](https://github.com/PrefectHQ/fastmcp/pull/4183) -* Add outbound comment guardrails by [@jlowin](https://github.com/jlowin) in [#4196](https://github.com/PrefectHQ/fastmcp/pull/4196) -* Add uv dependency cooldown by [@jlowin](https://github.com/jlowin) in [#4213](https://github.com/PrefectHQ/fastmcp/pull/4213) -### Fixes 🐞 -* fix: VersionSpec eq matching normalizes versions and selects deterministically by [@strawgate](https://github.com/strawgate) in [#4058](https://github.com/PrefectHQ/fastmcp/pull/4058) -* fix(tests): hoist azure-identity import out of the OBO test timeout window by [@strawgate](https://github.com/strawgate) in [#4176](https://github.com/PrefectHQ/fastmcp/pull/4176) -* fix(auth): disambiguate auth-denied vs missing component messages by [@strawgate](https://github.com/strawgate) in [#4165](https://github.com/PrefectHQ/fastmcp/pull/4165) -* fix: preserve annotations, meta, title, icons when creating resources from templates by [@strawgate](https://github.com/strawgate) in [#4061](https://github.com/PrefectHQ/fastmcp/pull/4061) -* fix: add OTEL spans to sampling step and tool execution by [@strawgate](https://github.com/strawgate) in [#4059](https://github.com/PrefectHQ/fastmcp/pull/4059) -* fix(config): read MCP config files as UTF-8 by [@pragnyanramtha](https://github.com/pragnyanramtha) in [#4164](https://github.com/PrefectHQ/fastmcp/pull/4164) -* fix(schema): preserve root metadata on fallback by [@yuyua9](https://github.com/yuyua9) in [#4178](https://github.com/PrefectHQ/fastmcp/pull/4178) -* fix(proxy): restore _current_server in _restore_request_context by [@strawgate](https://github.com/strawgate) in [#4168](https://github.com/PrefectHQ/fastmcp/pull/4168) -* fix(auth): add /.well-known/openid-configuration alias for OAuth server metadata by [@shigechika](https://github.com/shigechika) in [#4167](https://github.com/PrefectHQ/fastmcp/pull/4167) -* fix(code-mode): cancel Monty sandbox future on task cancellation by [@strawgate](https://github.com/strawgate) in [#4169](https://github.com/PrefectHQ/fastmcp/pull/4169) -* fix(auth): unprefix Azure scopes echoed back to MCP clients by [@rgillinlz](https://github.com/rgillinlz) in [#4130](https://github.com/PrefectHQ/fastmcp/pull/4130) -* fix(cli): forward stateless flag in uv run path by [@yuyua9](https://github.com/yuyua9) in [#4177](https://github.com/PrefectHQ/fastmcp/pull/4177) -* fix(ci): scope minimize-reviews concurrency by event name by [@strawgate](https://github.com/strawgate) in [#4174](https://github.com/PrefectHQ/fastmcp/pull/4174) -* Fix docs app demo iframe assets by [@jlowin](https://github.com/jlowin) in [#4194](https://github.com/PrefectHQ/fastmcp/pull/4194) -* Guard require-issue-link check job to pull_request_target events by [@jlowin](https://github.com/jlowin) in [#4209](https://github.com/PrefectHQ/fastmcp/pull/4209) -* Migrate auth JWTs to joserfc by [@jlowin](https://github.com/jlowin) in [#4221](https://github.com/PrefectHQ/fastmcp/pull/4221) -* Skip published docs update for prereleases by [@jlowin](https://github.com/jlowin) in [#4224](https://github.com/PrefectHQ/fastmcp/pull/4224) -* Surface proxy upstream failures by [@jlowin](https://github.com/jlowin) in [#4227](https://github.com/PrefectHQ/fastmcp/pull/4227) -* Close upstream OAuth clients by [@jlowin](https://github.com/jlowin) in [#4248](https://github.com/PrefectHQ/fastmcp/pull/4248) -* Fix GitHub MCP resource integration test by [@jlowin](https://github.com/jlowin) in [#4253](https://github.com/PrefectHQ/fastmcp/pull/4253) -* Fix resource templates with query params on proxied servers by [@rene84](https://github.com/rene84) in [#4251](https://github.com/PrefectHQ/fastmcp/pull/4251) -### Docs 📚 -* Document pip upgrade recovery for the fastmcp-slim package split by [@jlowin](https://github.com/jlowin) in [#4215](https://github.com/PrefectHQ/fastmcp/pull/4215) -* Move pip upgrade recovery into a Troubleshooting section by [@jlowin](https://github.com/jlowin) in [#4219](https://github.com/PrefectHQ/fastmcp/pull/4219) -* Restore Horizon docs banner by [@jlowin](https://github.com/jlowin) in [#4240](https://github.com/PrefectHQ/fastmcp/pull/4240) -* fix: Trendshift link and badge in README.md by [@bhantos](https://github.com/bhantos) in [#4236](https://github.com/PrefectHQ/fastmcp/pull/4236) -* docs: add tool fingerprinting recipe by [@dgenio](https://github.com/dgenio) in [#4233](https://github.com/PrefectHQ/fastmcp/pull/4233) -### Dependencies 📦 -* chore(deps): bump the uv group across 2 directories with 1 update by [@dependabot](https://github.com/dependabot) in [#4113](https://github.com/PrefectHQ/fastmcp/pull/4113) -* chore(deps-dev): bump pydantic-monty from 0.0.16 to 0.0.17 by [@dependabot](https://github.com/dependabot) in [#4023](https://github.com/PrefectHQ/fastmcp/pull/4023) -### Other Changes 🦾 -* Exempt maintainers from MRE auto-close by [@jlowin](https://github.com/jlowin) in [#4220](https://github.com/PrefectHQ/fastmcp/pull/4220) - -## New Contributors -* @pragnyanramtha made their first contribution in [#4164](https://github.com/PrefectHQ/fastmcp/pull/4164) -* @yuyua9 made their first contribution in [#4178](https://github.com/PrefectHQ/fastmcp/pull/4178) -* @tiagoskaneta made their first contribution in [#4135](https://github.com/PrefectHQ/fastmcp/pull/4135) -* @mohankumarelec made their first contribution in [#4142](https://github.com/PrefectHQ/fastmcp/pull/4142) -* @rgillinlz made their first contribution in [#4130](https://github.com/PrefectHQ/fastmcp/pull/4130) -* @jpr5 made their first contribution in [#4183](https://github.com/PrefectHQ/fastmcp/pull/4183) -* @bhantos made their first contribution in [#4236](https://github.com/PrefectHQ/fastmcp/pull/4236) -* @rene84 made their first contribution in [#4251](https://github.com/PrefectHQ/fastmcp/pull/4251) - -**Full Changelog**: [v3.3.1...v3.4.0](https://github.com/PrefectHQ/fastmcp/compare/v3.3.1...v3.4.0) - -</Update> - -<Update label="v3.3.1" description="2026-05-15"> - -**[v3.3.1: Loop There It Is](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.3.1)** - -A hotfix for the 3.3 packaging split. Clean installs could fail on standalone component imports like `from fastmcp.tools import tool`, because component modules reached auth and task primitives through `fastmcp.server` and pulled in the full server/provider stack. Those primitives now live in lightweight utility modules, with the old server import paths preserved as compatibility re-exports. - -### Fixes 🐞 -* fix(docs): use valid FA icon on client-only package page by [@jlowin](https://github.com/jlowin) in [#4139](https://github.com/PrefectHQ/fastmcp/pull/4139) -* Decouple component imports from server by [@jlowin](https://github.com/jlowin) in [#4150](https://github.com/PrefectHQ/fastmcp/pull/4150) - - -**Full Changelog**: [v3.3.0...v3.3.1](https://github.com/PrefectHQ/fastmcp/compare/v3.3.0...v3.3.1) - -</Update> - -<Update label="v3.3.0" description="2026-05-15"> - -**[v3.3.0: Slim Reaper](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.3.0)** - -FastMCP 3.3 ships `fastmcp-slim`, a dependency-light distribution that separates the client from the server stack — install FastMCP's client and transport layer without Starlette, Uvicorn, or the rest of the server machinery. The import namespace is unchanged. It also closes out a backlog of OAuth proxy security hardening, MCP-compliant OTEL instrumentation, and auth additions that accumulated through the 3.2 cycle. - -### New Features 🎉 -* Add fastmcp-slim for client-only installs by [@jlowin](https://github.com/jlowin) in [#4122](https://github.com/PrefectHQ/fastmcp/pull/4122) -### Enhancements ✨ -* Add default prefill to FormInput.collect_input by [@jlowin](https://github.com/jlowin) in [#3937](https://github.com/PrefectHQ/fastmcp/pull/3937) -* OTEL: Fix attribute compliance with MCP semantic conventions by [@strawgate](https://github.com/strawgate) in [#3889](https://github.com/PrefectHQ/fastmcp/pull/3889) -* OTEL: Instrument all MCP list operations and enrich delegate spans by [@strawgate](https://github.com/strawgate) in [#3890](https://github.com/PrefectHQ/fastmcp/pull/3890) -* Improve real-world schema crash test: failure dump, cluster analysis, TypeErrors baseline ratchet by [@jlowin](https://github.com/jlowin) in [#3958](https://github.com/PrefectHQ/fastmcp/pull/3958) -* feat: add AzureB2CProvider for Azure AD B2C user flows by [@carlos-rian](https://github.com/carlos-rian) in [#3995](https://github.com/PrefectHQ/fastmcp/pull/3995) -* Add run_in_thread opt-out for sync tools with thread affinity by [@jlowin](https://github.com/jlowin) in [#4010](https://github.com/PrefectHQ/fastmcp/pull/4010) -* Add missing return type annotation to __getattr__ by [@ZLeventer](https://github.com/ZLeventer) in [#4026](https://github.com/PrefectHQ/fastmcp/pull/4026) -* Add experimental_capabilities kwarg to FastMCP constructor by [@jlowin](https://github.com/jlowin) in [#4042](https://github.com/PrefectHQ/fastmcp/pull/4042) -* Add log_level parameter to FastMCP errors by [@daniel-tsiang](https://github.com/daniel-tsiang) in [#4036](https://github.com/PrefectHQ/fastmcp/pull/4036) -* Bump pydocket to 0.20.0 by [@chrisguidry](https://github.com/chrisguidry) in [#4031](https://github.com/PrefectHQ/fastmcp/pull/4031) -* enh: Add public API for updating OAuthProxy scopes after initialization by [@taylorwilsdon](https://github.com/taylorwilsdon) in [#4091](https://github.com/PrefectHQ/fastmcp/pull/4091) -* Refine fastmcp-slim packaging by [@jlowin](https://github.com/jlowin) in [#4125](https://github.com/PrefectHQ/fastmcp/pull/4125) -### Security 🔒 -* Harden OAuth Proxy silent consent against AS-in-the-middle by [@jlowin](https://github.com/jlowin) in [#3960](https://github.com/PrefectHQ/fastmcp/pull/3960) -* Reject dot-segments in redirect URI allowlist matching by [@jlowin](https://github.com/jlowin) in [#3963](https://github.com/PrefectHQ/fastmcp/pull/3963) -* Bump deps with open dependabot alerts by [@jlowin](https://github.com/jlowin) in [#3965](https://github.com/PrefectHQ/fastmcp/pull/3965) -* Partition ResponseCachingMiddleware cache by access token by [@jlowin](https://github.com/jlowin) in [#4041](https://github.com/PrefectHQ/fastmcp/pull/4041) -### Fixes 🐞 -* fix: reject self-mount to prevent infinite recursion by [@strawgate](https://github.com/strawgate) in [#3925](https://github.com/PrefectHQ/fastmcp/pull/3925) -* fix: ProxyTool crashes on non-TextContent error responses by [@strawgate](https://github.com/strawgate) in [#3926](https://github.com/PrefectHQ/fastmcp/pull/3926) -* fix: _prune_param and _convert_nullable_field mutate input schemas by [@strawgate](https://github.com/strawgate) in [#3927](https://github.com/PrefectHQ/fastmcp/pull/3927) -* fix: narrow OpenAI audio format dict to Literal for ty by [@jlowin](https://github.com/jlowin) in [#3936](https://github.com/PrefectHQ/fastmcp/pull/3936) -* fix: allow hyphens in resource template parameter names by [@strawgate](https://github.com/strawgate) in [#3929](https://github.com/PrefectHQ/fastmcp/pull/3929) -* fix: OpenAPI request director sends multipart and form-urlencoded as JSON by [@strawgate](https://github.com/strawgate) in [#3932](https://github.com/PrefectHQ/fastmcp/pull/3932) -* Fix raise_on_error handling for tool tasks by [@gnanirahulnutakki](https://github.com/gnanirahulnutakki) in [#3946](https://github.com/PrefectHQ/fastmcp/pull/3946) -* fix: FileSystemProvider reload race condition by [@strawgate](https://github.com/strawgate) in [#3938](https://github.com/PrefectHQ/fastmcp/pull/3938) -* fix tests that relied on task=True returning error results by [@jlowin](https://github.com/jlowin) in [#3954](https://github.com/PrefectHQ/fastmcp/pull/3954) -* Restore task snapshot via a worker-level dependency by [@chrisguidry](https://github.com/chrisguidry) in [#3945](https://github.com/PrefectHQ/fastmcp/pull/3945) -* Forward backend capabilities in ProxyProvider by [@jlowin](https://github.com/jlowin) in [#3956](https://github.com/PrefectHQ/fastmcp/pull/3956) -* Allow upstream client_id to be used directly without DCR by [@jlowin](https://github.com/jlowin) in [#3957](https://github.com/PrefectHQ/fastmcp/pull/3957) -* Graceful fallback for unsupported regex patterns in json_schema_to_type by [@jlowin](https://github.com/jlowin) in [#3959](https://github.com/PrefectHQ/fastmcp/pull/3959) -* Revert "Forward backend capabilities in ProxyProvider (#3956)" by [@jlowin](https://github.com/jlowin) in [#3964](https://github.com/PrefectHQ/fastmcp/pull/3964) -* fix: skip stdio subprocess test on Windows CI by [@jlowin](https://github.com/jlowin) in [#3966](https://github.com/PrefectHQ/fastmcp/pull/3966) -* fix: bound _refresh_locks with LRU eviction to prevent memory leak by [@jlowin](https://github.com/jlowin) in [#3968](https://github.com/PrefectHQ/fastmcp/pull/3968) -* fix: handle circular JSON Pointer $ref in dereference_refs by [@lawrence3699](https://github.com/lawrence3699) in [#3896](https://github.com/PrefectHQ/fastmcp/pull/3896) -* fix: honor upstream refresh token expiry in OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3990](https://github.com/PrefectHQ/fastmcp/pull/3990) -* fix: narrow _token_validator with isinstance for ty in AzureProvider.from_b2c by [@jlowin](https://github.com/jlowin) in [#4007](https://github.com/PrefectHQ/fastmcp/pull/4007) -* fix: cancel orphaned session_task when Client._disconnect times out by [@jlowin](https://github.com/jlowin) in [#4011](https://github.com/PrefectHQ/fastmcp/pull/4011) -* fix: preserve @tool metadata in from_function by [@lawrence3699](https://github.com/lawrence3699) in [#4072](https://github.com/PrefectHQ/fastmcp/pull/4072) -* fix(openapi): keep blank values in parse_qs (refs #4056) by [@MukundaKatta](https://github.com/MukundaKatta) in [#4076](https://github.com/PrefectHQ/fastmcp/pull/4076) -* Fix #4056: keep blank query values, add token bucket regression test by [@MukundaKatta](https://github.com/MukundaKatta) in [#4069](https://github.com/PrefectHQ/fastmcp/pull/4069) -* fix(ping): exit ping loop cleanly when session stream is closed by [@ashwin153](https://github.com/ashwin153) in [#4087](https://github.com/PrefectHQ/fastmcp/pull/4087) -* Fix sampling from background tasks by [@cuyua9](https://github.com/cuyua9) in [#4068](https://github.com/PrefectHQ/fastmcp/pull/4068) -* Make Docket reentrant; mounted servers enter their own lifespan by [@jlowin](https://github.com/jlowin) in [#4095](https://github.com/PrefectHQ/fastmcp/pull/4095) -* fix(tool_transform): hoist $defs to schema root when ArgTransform introduces them by [@SarthakB11](https://github.com/SarthakB11) in [#4101](https://github.com/PrefectHQ/fastmcp/pull/4101) -* fix(auth): silence authlib.jose DeprecationWarning at JWT import by [@SarthakB11](https://github.com/SarthakB11) in [#4100](https://github.com/PrefectHQ/fastmcp/pull/4100) -* fix: don't cache import map in dev apps bundle by [@jlowin](https://github.com/jlowin) in [#4106](https://github.com/PrefectHQ/fastmcp/pull/4106) -* #4084 [Issues] Windows startup crash due to UnicodeDecodeError when l… by [@doneman536](https://github.com/doneman536) in [#4092](https://github.com/PrefectHQ/fastmcp/pull/4092) -* fix: drop exc_info for expected tool failures, remove unreachable ValidationError by [@sergeykad](https://github.com/sergeykad) in [#4029](https://github.com/PrefectHQ/fastmcp/pull/4029) -* fix: cli option --no-banner is NOT passed to cli but server-spec in-correctly when cli --reload option is specified. by [@itaru2622](https://github.com/itaru2622) in [#4083](https://github.com/PrefectHQ/fastmcp/pull/4083) -* Fix None backend_* span attributes on un-renamed proxy components by [@ringerc](https://github.com/ringerc) in [#4109](https://github.com/PrefectHQ/fastmcp/pull/4109) -* Fix OCI Provider issue in 3.x version. Add OCI auth provider example … by [@kiranthakkar](https://github.com/kiranthakkar) in [#4116](https://github.com/PrefectHQ/fastmcp/pull/4116) -* fix(http): terminate active streamable-HTTP transports before lifespan shutdown by [@SarthakB11](https://github.com/SarthakB11) in [#4118](https://github.com/PrefectHQ/fastmcp/pull/4118) -### Docs 📚 -* Restructure docs navigation by [@jlowin](https://github.com/jlowin) in [#3951](https://github.com/PrefectHQ/fastmcp/pull/3951) -* docs: standardize ToolAnnotations examples by [@gnanirahulnutakki](https://github.com/gnanirahulnutakki) in [#3952](https://github.com/PrefectHQ/fastmcp/pull/3952) -* Be constructively skeptical of bot reviews on own PRs by [@jlowin](https://github.com/jlowin) in [#3971](https://github.com/PrefectHQ/fastmcp/pull/3971) -* Add UTM params to Horizon docs links by [@aaazzam](https://github.com/aaazzam) in [#4018](https://github.com/PrefectHQ/fastmcp/pull/4018) -* Add a sandboxed-agents deployment guide by [@strawgate](https://github.com/strawgate) in [#4027](https://github.com/PrefectHQ/fastmcp/pull/4027) -* docs: add best practices for custom telemetry spans by [@MukundaKatta](https://github.com/MukundaKatta) in [#4001](https://github.com/PrefectHQ/fastmcp/pull/4001) -* Refresh landing page copy by [@jlowin](https://github.com/jlowin) in [#4043](https://github.com/PrefectHQ/fastmcp/pull/4043) -* Refresh landing page copy by [@jlowin](https://github.com/jlowin) in [#4047](https://github.com/PrefectHQ/fastmcp/pull/4047) -* Add UTM tracking to Horizon links by [@jlowin](https://github.com/jlowin) in [#4064](https://github.com/PrefectHQ/fastmcp/pull/4064) -* docs(integrations): add Pydantic AI FastMCP toolset guide by [@MukundaKatta](https://github.com/MukundaKatta) in [#4070](https://github.com/PrefectHQ/fastmcp/pull/4070) -* docs: fix broken links in Pydantic AI guide by [@jlowin](https://github.com/jlowin) in [#4094](https://github.com/PrefectHQ/fastmcp/pull/4094) -### Dependencies 📦 -* chore(deps-dev): bump pydantic-monty from 0.0.11 to 0.0.12 by [@dependabot](https://github.com/dependabot) in [#3940](https://github.com/PrefectHQ/fastmcp/pull/3940) -* chore(deps-dev): bump pydantic-monty from 0.0.14 to 0.0.16 by [@dependabot](https://github.com/dependabot) in [#3984](https://github.com/PrefectHQ/fastmcp/pull/3984) -### Other Changes 🦾 -* fix: Don't completely hide plain mcp.tool app-only tools by [@owtaylor](https://github.com/owtaylor) in [#4112](https://github.com/PrefectHQ/fastmcp/pull/4112) - -## New Contributors -* @gnanirahulnutakki made their first contribution in [#3946](https://github.com/PrefectHQ/fastmcp/pull/3946) -* @lawrence3699 made their first contribution in [#3896](https://github.com/PrefectHQ/fastmcp/pull/3896) -* @carlos-rian made their first contribution in [#3995](https://github.com/PrefectHQ/fastmcp/pull/3995) -* @ZLeventer made their first contribution in [#4026](https://github.com/PrefectHQ/fastmcp/pull/4026) -* @MukundaKatta made their first contribution in [#4001](https://github.com/PrefectHQ/fastmcp/pull/4001) -* @daniel-tsiang made their first contribution in [#4036](https://github.com/PrefectHQ/fastmcp/pull/4036) -* @ashwin153 made their first contribution in [#4087](https://github.com/PrefectHQ/fastmcp/pull/4087) -* @cuyua9 made their first contribution in [#4068](https://github.com/PrefectHQ/fastmcp/pull/4068) -* @taylorwilsdon made their first contribution in [#4091](https://github.com/PrefectHQ/fastmcp/pull/4091) -* @SarthakB11 made their first contribution in [#4101](https://github.com/PrefectHQ/fastmcp/pull/4101) -* @doneman536 made their first contribution in [#4092](https://github.com/PrefectHQ/fastmcp/pull/4092) -* @sergeykad made their first contribution in [#4029](https://github.com/PrefectHQ/fastmcp/pull/4029) -* @ringerc made their first contribution in [#4109](https://github.com/PrefectHQ/fastmcp/pull/4109) - -**Full Changelog**: [v3.2.4...v3.3.0](https://github.com/PrefectHQ/fastmcp/compare/v3.2.4...v3.3.0) - -</Update> - -<Update label="v3.2.4" description="2026-04-14"> - -**[v3.2.4: Patch Me If You Can](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.4)** - -A grab bag of fixes, hardening, and polish. The headline behavior change: background tasks are now scoped to the authorization context rather than the MCP session, so a task survives session churn and stays tied to who started it — a breaking change for anyone relying on the old session-scoped semantics. Plus actual-size validation in `FileUpload`, a Keycloak OAuth provider, automatic parameter descriptions from docstrings, and dozens of schema and sampling fixes. - -### Breaking Changes ⚠️ -* Scope tasks to authorization context, not session by [@chrisguidry](https://github.com/chrisguidry) in [#3800](https://github.com/PrefectHQ/fastmcp/pull/3800) -### Enhancements ✨ -* Bump pydocket>=0.19.0, drop fakeredis pin by [@chrisguidry](https://github.com/chrisguidry) in [#3822](https://github.com/PrefectHQ/fastmcp/pull/3822) -* Add real-world schema crash test (232K schemas from APIs.guru) by [@strawgate](https://github.com/strawgate) in [#3826](https://github.com/PrefectHQ/fastmcp/pull/3826) -* Enable 7 zero-violation ruff rules by [@strawgate](https://github.com/strawgate) in [#3841](https://github.com/PrefectHQ/fastmcp/pull/3841) -* Promote 7 ty rules from ignore to warn by [@strawgate](https://github.com/strawgate) in [#3852](https://github.com/PrefectHQ/fastmcp/pull/3852) -* Replace ___ with hash-based backend tool routing and per-tool prefab resources by [@jlowin](https://github.com/jlowin) in [#3824](https://github.com/PrefectHQ/fastmcp/pull/3824) -* Enable 4 ruff rules (DTZ, ERA, ISC, INP) and fix 9 violations by [@strawgate](https://github.com/strawgate) in [#3842](https://github.com/PrefectHQ/fastmcp/pull/3842) -* Extract parameter descriptions from docstrings by [@jlowin](https://github.com/jlowin) in [#3872](https://github.com/PrefectHQ/fastmcp/pull/3872) -* ci: speed up schema crash test (CSafeLoader + xdist-safe aggregation) by [@jlowin](https://github.com/jlowin) in [#3873](https://github.com/PrefectHQ/fastmcp/pull/3873) -* test: bump OpenAPI init perf threshold to 200ms for Windows CI by [@jlowin](https://github.com/jlowin) in [#3879](https://github.com/PrefectHQ/fastmcp/pull/3879) -* refactor: unify object-schema conversion through _object_schema_to_type by [@jlowin](https://github.com/jlowin) in [#3884](https://github.com/PrefectHQ/fastmcp/pull/3884) -* Add Keycloak OAuth Provider for Enterprise Authentication and local dev by [@stephaneberle9](https://github.com/stephaneberle9) in [#1937](https://github.com/PrefectHQ/fastmcp/pull/1937) -* Allow auth providers to override protected resource base URLs by [@aaazzam](https://github.com/aaazzam) in [#3900](https://github.com/PrefectHQ/fastmcp/pull/3900) -* Enable PERF and T20 ruff rules by [@strawgate](https://github.com/strawgate) in [#3845](https://github.com/PrefectHQ/fastmcp/pull/3845) -* Add response_title and response_description to ctx.elicit() by [@jlowin](https://github.com/jlowin) in [#3912](https://github.com/PrefectHQ/fastmcp/pull/3912) -* Deprecate ctx.elicit() without response_type by [@jlowin](https://github.com/jlowin) in [#3916](https://github.com/PrefectHQ/fastmcp/pull/3916) -### Security 🔒 -* Validate actual base64 data size in FileUpload, not client-reported size by [@strawgate](https://github.com/strawgate) in [#3816](https://github.com/PrefectHQ/fastmcp/pull/3816) -* Stop forwarding inbound HTTP headers to unrelated remote servers by [@jlowin](https://github.com/jlowin) in [#3837](https://github.com/PrefectHQ/fastmcp/pull/3837) -* AuthKit: auto-bind token audience to resource URL (RFC 8707) by [@jlowin](https://github.com/jlowin) in [#3905](https://github.com/PrefectHQ/fastmcp/pull/3905) -### Fixes 🐞 -* Version-check is_docket_available() to avoid transitive pydocket crash by [@jlowin](https://github.com/jlowin) in [#3807](https://github.com/PrefectHQ/fastmcp/pull/3807) -* fix: materialize generators before result conversion, handle bytes gracefully by [@strawgate](https://github.com/strawgate) in [#3830](https://github.com/PrefectHQ/fastmcp/pull/3830) -* Fix json_schema_to_type crashes on keywords, boolean schemas, empty enums, and name collisions by [@strawgate](https://github.com/strawgate) in [#3818](https://github.com/PrefectHQ/fastmcp/pull/3818) -* fix: replace `or` with `is not None` checks for config/override merging by [@strawgate](https://github.com/strawgate) in [#3833](https://github.com/PrefectHQ/fastmcp/pull/3833) -* fix: TransformedTool sync fn crash and schema mutation by [@strawgate](https://github.com/strawgate) in [#3823](https://github.com/PrefectHQ/fastmcp/pull/3823) -* fix: cross-provider duplicate detection, error visibility, mask propagation by [@strawgate](https://github.com/strawgate) in [#3827](https://github.com/PrefectHQ/fastmcp/pull/3827) -* fix: don't pass HTTP kwargs when transport is unspecified by [@strawgate](https://github.com/strawgate) in [#3838](https://github.com/PrefectHQ/fastmcp/pull/3838) -* fix: strip title fields from tool schemas for Gemini 2.5 Flash compatibility by [@strawgate](https://github.com/strawgate) in [#3861](https://github.com/PrefectHQ/fastmcp/pull/3861) -* fix: retry when LLM returns text instead of calling final_response by [@strawgate](https://github.com/strawgate) in [#3850](https://github.com/PrefectHQ/fastmcp/pull/3850) -* Raise on unhandled content types in sampling handler dispatch chains by [@strawgate](https://github.com/strawgate) in [#3857](https://github.com/PrefectHQ/fastmcp/pull/3857) -* Fix broken code examples in docs by [@strawgate](https://github.com/strawgate) in [#3869](https://github.com/PrefectHQ/fastmcp/pull/3869) -* fix: GoogleGenaiSamplingHandler leaks thought parts and gives unhelpful errors on empty responses by [@strawgate](https://github.com/strawgate) in [#3849](https://github.com/PrefectHQ/fastmcp/pull/3849) -* fix: cap consecutive final_response validation retries by [@strawgate](https://github.com/strawgate) in [#3851](https://github.com/PrefectHQ/fastmcp/pull/3851) -* Fix test quality issues by [@strawgate](https://github.com/strawgate) in [#3854](https://github.com/PrefectHQ/fastmcp/pull/3854) -* Fix MCP tool on docs welcome page by [@lkiesow](https://github.com/lkiesow) in [#3874](https://github.com/PrefectHQ/fastmcp/pull/3874) -* Fix CIMD clients getting required_scopes instead of valid_scopes by [@jlowin](https://github.com/jlowin) in [#3836](https://github.com/PrefectHQ/fastmcp/pull/3836) -* Rename filesystem-provider example dir to avoid mcp/ collision by [@jlowin](https://github.com/jlowin) in [#3878](https://github.com/PrefectHQ/fastmcp/pull/3878) -* fix: drop configurable dedupe from AggregateProvider, always warn by [@jlowin](https://github.com/jlowin) in [#3877](https://github.com/PrefectHQ/fastmcp/pull/3877) -* fix: resolve list[dict] return type producing Root() instead of dicts by [@KeWang0622](https://github.com/KeWang0622) in [#3880](https://github.com/PrefectHQ/fastmcp/pull/3880) -* fix: strip titles from bare-metadata nodes (Gemini 2.5 Flash) by [@jlowin](https://github.com/jlowin) in [#3881](https://github.com/PrefectHQ/fastmcp/pull/3881) -* Fix wildcard resource template params in mounted servers by [@jlowin](https://github.com/jlowin) in [#3899](https://github.com/PrefectHQ/fastmcp/pull/3899) -* Harden forced client disconnect cleanup by [@vonbai](https://github.com/vonbai) in [#3885](https://github.com/PrefectHQ/fastmcp/pull/3885) -* fix: elicitation scalar return, resource auto-serialization, Client.new() state, prompt errors by [@strawgate](https://github.com/strawgate) in [#3859](https://github.com/PrefectHQ/fastmcp/pull/3859) -* fix: task.wait() hangs indefinitely when task enters input_required by [@mrishav](https://github.com/mrishav) in [#3798](https://github.com/PrefectHQ/fastmcp/pull/3798) -* Fix RetryMiddleware not retrying tool errors by [@strawgate](https://github.com/strawgate) in [#3858](https://github.com/PrefectHQ/fastmcp/pull/3858) -* Stop pydantic 2.13 from leaking _WrappedResult docstring into tool output schemas by [@jlowin](https://github.com/jlowin) in [#3918](https://github.com/PrefectHQ/fastmcp/pull/3918) -### Docs 📚 -* Note generate-notes API in release workflow docs by [@jlowin](https://github.com/jlowin) in [#3806](https://github.com/PrefectHQ/fastmcp/pull/3806) -* docs: require agents to respect DNM markers on PRs by [@jlowin](https://github.com/jlowin) in [#3871](https://github.com/PrefectHQ/fastmcp/pull/3871) -* docs: add uv-managed dependencies and uvx examples to mcp-json configuration by [@vincent067](https://github.com/vincent067) in [#3843](https://github.com/PrefectHQ/fastmcp/pull/3843) -* docs: link fastmcp-keycloak-local companion project from Keycloak integration page by [@stephaneberle9](https://github.com/stephaneberle9) in [#3904](https://github.com/PrefectHQ/fastmcp/pull/3904) -* Overhaul apps docs by [@jlowin](https://github.com/jlowin) in [#3915](https://github.com/PrefectHQ/fastmcp/pull/3915) -### Dependencies 📦 -* chore(deps): bump extractions/setup-just from 3 to 4 by [@dependabot](https://github.com/dependabot) in [#3863](https://github.com/PrefectHQ/fastmcp/pull/3863) -* chore(deps): bump astral-sh/setup-uv from 6 to 7 by [@dependabot](https://github.com/dependabot) in [#3865](https://github.com/PrefectHQ/fastmcp/pull/3865) -* chore(deps): bump actions/checkout from 4 to 6 by [@dependabot](https://github.com/dependabot) in [#3864](https://github.com/PrefectHQ/fastmcp/pull/3864) -* chore(deps-dev): bump pydantic-monty from 0.0.9 to 0.0.10 by [@dependabot](https://github.com/dependabot) in [#3809](https://github.com/PrefectHQ/fastmcp/pull/3809) -* chore(deps): bump the uv group across 2 directories with 1 update by [@dependabot](https://github.com/dependabot) in [#3913](https://github.com/PrefectHQ/fastmcp/pull/3913) - -## New Contributors -* @lkiesow made their first contribution in [#3874](https://github.com/PrefectHQ/fastmcp/pull/3874) -* @KeWang0622 made their first contribution in [#3880](https://github.com/PrefectHQ/fastmcp/pull/3880) -* @vonbai made their first contribution in [#3885](https://github.com/PrefectHQ/fastmcp/pull/3885) - -**Full Changelog**: [v3.2.3...v3.2.4](https://github.com/PrefectHQ/fastmcp/compare/v3.2.3...v3.2.4) - -</Update> - -<Update label="v3.2.3" description="2026-04-09"> - -**[v3.2.3: Redis or Not](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.3)** - -A stopgap pin: fakeredis 2.35.0 shipped an undocumented rename that broke pydocket's `memory://` backend, causing `fastmcp[tasks]` installs to fail at startup with an `ImportError`. This pins `fakeredis<2.35.0` in the `tasks` extra until a fixed pydocket ships. - -### Fixes 🐞 -* Pin `fakeredis<2.35.0` in tasks extra by [@jlowin](https://github.com/jlowin) in [#3804](https://github.com/PrefectHQ/fastmcp/pull/3804) -### Docs 📚 -* Document session state isolation across mount boundaries by [@jlowin](https://github.com/jlowin) in [#3801](https://github.com/PrefectHQ/fastmcp/pull/3801) - - -**Full Changelog**: [v3.2.2...v3.2.3](https://github.com/PrefectHQ/fastmcp/compare/v3.2.2...v3.2.3) - -</Update> - -<Update label="v3.2.2" description="2026-04-09"> - -**[v3.2.2: Audience Appreciation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.2)** - -Fixes the Azure audience regression from 3.2.1: validation switched from `client_id` to `identifier_uri`, which fixed custom Application ID URIs but broke the default case where Azure AD v2 tokens set `aud` to the bare client ID GUID. Both formats are now accepted. - -### Fixes 🐞 -* fix: accept both client_id and identifier_uri as Azure audience by [@jlowin](https://github.com/jlowin) in [#3797](https://github.com/PrefectHQ/fastmcp/pull/3797) -### Dependencies 📦 -* chore(deps): bump the uv group across 2 directories with 1 update by [@dependabot](https://github.com/dependabot) in [#3795](https://github.com/PrefectHQ/fastmcp/pull/3795) - - -**Full Changelog**: [v3.2.1...v3.2.2](https://github.com/PrefectHQ/fastmcp/compare/v3.2.1...v3.2.2) - -</Update> - -<Update label="v3.2.1" description="2026-04-08"> - -**[v3.2.1: Audience Participation](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.1)** - -A patch focused on auth-provider audience validation. Cognito tokens now validate on `client_id` (they carry no `aud`), Azure honors the `identifier_uri` parameter for Entra v2.0 tokens, and consent cookies are LRU-capped to prevent unbounded growth past reverse proxy header limits. Also fixes OpenAPI 3.0 `nullable` fields leaking into tool input schemas and server-variable substitution in base URLs. - -### Breaking Changes ⚠️ -* fix(google): use sub (user ID) for client_id instead of aud (app ID) by [@shigechika](https://github.com/shigechika) in [#3722](https://github.com/PrefectHQ/fastmcp/pull/3722) -* fix: remove CSP from tool metadata, keep on resource only by [@jlowin](https://github.com/jlowin) in [#3754](https://github.com/PrefectHQ/fastmcp/pull/3754) -### Enhancements ✨ -* [codex] Add FastMCP docs telemetry by [@aaazzam](https://github.com/aaazzam) in [#3727](https://github.com/PrefectHQ/fastmcp/pull/3727) -* chore: split SDK navigation into standalone $ref file by [@jlowin](https://github.com/jlowin) in [#3773](https://github.com/PrefectHQ/fastmcp/pull/3773) -* fix: bump ty to >=0.0.29 and suppress new false positives by [@jlowin](https://github.com/jlowin) in [#3790](https://github.com/PrefectHQ/fastmcp/pull/3790) -### Fixes 🐞 -* fix: use explicit None checks for JWT exp validation by [@jlowin](https://github.com/jlowin) in [#3724](https://github.com/PrefectHQ/fastmcp/pull/3724) -* Unify background task context forwarding, fix concurrent dependency bugs by [@chrisguidry](https://github.com/chrisguidry) in [#3710](https://github.com/PrefectHQ/fastmcp/pull/3710) -* fix: add proxy timeouts and modernize networking in apps dev by [@mateeaaa](https://github.com/mateeaaa) in [#3741](https://github.com/PrefectHQ/fastmcp/pull/3741) -* fix: ResponseLimitingMiddleware no longer breaks outputSchema tools by [@jlowin](https://github.com/jlowin) in [#3756](https://github.com/PrefectHQ/fastmcp/pull/3756) -* fix: substitute server variable defaults when building base URL from OpenAPI spec by [@mrishav](https://github.com/mrishav) in [#3770](https://github.com/PrefectHQ/fastmcp/pull/3770) -* fix: FastAPI TestClient compatibility and lifespan re-initialization by [@kvdhanush06](https://github.com/kvdhanush06) in [#3736](https://github.com/PrefectHQ/fastmcp/pull/3736) -* fix: propagate upstream_claims in load_access_token by [@kvdhanush06](https://github.com/kvdhanush06) in [#3750](https://github.com/PrefectHQ/fastmcp/pull/3750) -* Remove deprecated asyncio.iscoroutinefunction fallback by [@kaiisfree](https://github.com/kaiisfree) in [#3767](https://github.com/PrefectHQ/fastmcp/pull/3767) -* fix: changeable allowed_client_redirect_uris on OAuthProxy by [@fengarix](https://github.com/fengarix) in [#3772](https://github.com/PrefectHQ/fastmcp/pull/3772) -* fix: broken link in changelog by [@jlowin](https://github.com/jlowin) in [#3775](https://github.com/PrefectHQ/fastmcp/pull/3775) -* fix(docs): correct FastMCP tool name in welcome docs by [@buyua9](https://github.com/buyua9) in [#3781](https://github.com/PrefectHQ/fastmcp/pull/3781) -* fix: cap consent cookie size to prevent header overflow by [@jlowin](https://github.com/jlowin) in [#3784](https://github.com/PrefectHQ/fastmcp/pull/3784) -* Fix boolean property schemas in JSON Schema parsing by [@jlowin](https://github.com/jlowin) in [#3785](https://github.com/PrefectHQ/fastmcp/pull/3785) -* Fix OpenAPI 3.0 nullable fields in tool input schemas by [@kvdhanush06](https://github.com/kvdhanush06) in [#3768](https://github.com/PrefectHQ/fastmcp/pull/3768) -* fix: Cognito token verification checks client_id instead of aud by [@jlowin](https://github.com/jlowin) in [#3786](https://github.com/PrefectHQ/fastmcp/pull/3786) -* fix: use identifier_uri as audience for Azure token validation by [@jlowin](https://github.com/jlowin) in [#3787](https://github.com/PrefectHQ/fastmcp/pull/3787) -* Harden client tool result error handling by [@aimable100](https://github.com/aimable100) in [#3778](https://github.com/PrefectHQ/fastmcp/pull/3778) -### Docs 📚 -* Github integraiton documentation fix: use result.data otherwise CallToolResult not scriptable by [@c4jquick](https://github.com/c4jquick) in [#3753](https://github.com/PrefectHQ/fastmcp/pull/3753) -* chore: split v2 docs navigation into separate file by [@jlowin](https://github.com/jlowin) in [#3762](https://github.com/PrefectHQ/fastmcp/pull/3762) -* docs: document forward_resource parameter on OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3788](https://github.com/PrefectHQ/fastmcp/pull/3788) -### Examples & Contrib 💡 -* fix: boolean false values dropped in form submissions by [@jlowin](https://github.com/jlowin) in [#3776](https://github.com/PrefectHQ/fastmcp/pull/3776) -### Dependencies 📦 -* chore(deps): bump fastmcp from 3.1.1 to 3.2.0 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3728](https://github.com/PrefectHQ/fastmcp/pull/3728) -* chore(deps): bump anthropic from 0.86.0 to 0.87.0 in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3742](https://github.com/PrefectHQ/fastmcp/pull/3742) - -## New Contributors -* @c4jquick made their first contribution in [#3753](https://github.com/PrefectHQ/fastmcp/pull/3753) -* @mateeaaa made their first contribution in [#3741](https://github.com/PrefectHQ/fastmcp/pull/3741) -* @mrishav made their first contribution in [#3770](https://github.com/PrefectHQ/fastmcp/pull/3770) -* @kvdhanush06 made their first contribution in [#3736](https://github.com/PrefectHQ/fastmcp/pull/3736) -* @kaiisfree made their first contribution in [#3767](https://github.com/PrefectHQ/fastmcp/pull/3767) -* @fengarix made their first contribution in [#3772](https://github.com/PrefectHQ/fastmcp/pull/3772) -* @buyua9 made their first contribution in [#3781](https://github.com/PrefectHQ/fastmcp/pull/3781) -* @aimable100 made their first contribution in [#3778](https://github.com/PrefectHQ/fastmcp/pull/3778) - -**Full Changelog**: [v3.2.0...v3.2.1](https://github.com/PrefectHQ/fastmcp/compare/v3.2.0...v3.2.1) - -</Update> - -<Update label="v3.2.0" description="2026-03-30"> - -**[v3.2.0: Show Don't Tool](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.0)** - -FastMCP 3.2 is the Apps release: your tools can now return interactive UIs — charts, dashboards, forms, maps — rendered right inside the conversation. `FastMCPApp` separates the tools the LLM sees from the backend tools the UI calls, five built-in providers (FileUpload, Approval, Choice, FormInput, GenerativeUI) cover common interaction patterns, and `fastmcp dev apps` gives you a browser preview. The release also lands a significant security hardening pass across SSRF/path-traversal, JWT algorithm restrictions, OAuth scope enforcement, and CSRF. - -### New Features 🎉 -* Add FastMCPApp — a Provider for composable MCP applications by [@jlowin](https://github.com/jlowin) in [#3385](https://github.com/PrefectHQ/fastmcp/pull/3385) -* Add fastmcp dev apps command with browser UI preview by [@jlowin](https://github.com/jlowin) in [#3489](https://github.com/PrefectHQ/fastmcp/pull/3489) -* Add GenerativeUI provider, bump prefab-ui 0.14.0 by [@jlowin](https://github.com/jlowin) in [#3647](https://github.com/PrefectHQ/fastmcp/pull/3647) -* Add FileUpload provider by [@jlowin](https://github.com/jlowin) in [#3669](https://github.com/PrefectHQ/fastmcp/pull/3669) -* Add Approval and Choice providers by [@jlowin](https://github.com/jlowin) in [#3686](https://github.com/PrefectHQ/fastmcp/pull/3686) -* Add FormInput provider, bump prefab-ui to 0.15.0 by [@jlowin](https://github.com/jlowin) in [#3687](https://github.com/PrefectHQ/fastmcp/pull/3687) -### Breaking Changes ⚠️ -* Route app tool calls via ___-prefixed names by [@jlowin](https://github.com/jlowin) in [#3667](https://github.com/PrefectHQ/fastmcp/pull/3667) -### Enhancements ✨ -* feat: add `--config-path` flag to claude-desktop install command by [@Sumanshu-Nankana](https://github.com/Sumanshu-Nankana) in [#3380](https://github.com/PrefectHQ/fastmcp/pull/3380) -* Support ImageContent and AudioContent in Message class by [@ericrobinson-indeed](https://github.com/ericrobinson-indeed) in [#3396](https://github.com/PrefectHQ/fastmcp/pull/3396) -* Deprecate PromptToolMiddleware and ResourceToolMiddleware by [@jlowin](https://github.com/jlowin) in [#3389](https://github.com/PrefectHQ/fastmcp/pull/3389) -* Block HS* algorithms when JWTVerifier is configured with JWKS by [@jlowin](https://github.com/jlowin) in [#3419](https://github.com/PrefectHQ/fastmcp/pull/3419) -* Remove prek from Marvin workflows by [@jlowin](https://github.com/jlowin) in [#3444](https://github.com/PrefectHQ/fastmcp/pull/3444) -* Add dependency version compatibility guidance to code-review skill by [@jlowin](https://github.com/jlowin) in [#3475](https://github.com/PrefectHQ/fastmcp/pull/3475) -* Remove "good first issue" label by [@jlowin](https://github.com/jlowin) in [#3482](https://github.com/PrefectHQ/fastmcp/pull/3482) -* Cache component lists in ProxyProvider by [@jlowin](https://github.com/jlowin) in [#3479](https://github.com/PrefectHQ/fastmcp/pull/3479) -* Support logging/setLevel and add client_log_level by [@jlowin](https://github.com/jlowin) in [#3491](https://github.com/PrefectHQ/fastmcp/pull/3491) -* Propagate x-fastmcp-wrap-result in tool result _meta by [@jlowin](https://github.com/jlowin) in [#3490](https://github.com/PrefectHQ/fastmcp/pull/3490) -* feat(auth): add external_consent param to suppress misleading warning by [@mtthidoteu](https://github.com/mtthidoteu) in [#3473](https://github.com/PrefectHQ/fastmcp/pull/3473) -* Add `verify` parameter for SSL certificate configuration by [@jlowin](https://github.com/jlowin) in [#3487](https://github.com/PrefectHQ/fastmcp/pull/3487) -* Expose minimum_check_interval, reduce task pickup latency by [@jlowin](https://github.com/jlowin) in [#3500](https://github.com/PrefectHQ/fastmcp/pull/3500) -* Fix test timeouts, suppress deprecation warnings, speed up auth tests by [@jlowin](https://github.com/jlowin) in [#3504](https://github.com/PrefectHQ/fastmcp/pull/3504) -* Auto-close upgrade check issue when build passes by [@jlowin](https://github.com/jlowin) in [#3505](https://github.com/PrefectHQ/fastmcp/pull/3505) -* feat: make upstream_client_secret optional in OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3486](https://github.com/PrefectHQ/fastmcp/pull/3486) -* Add security label to triage workflow and release notes by [@jlowin](https://github.com/jlowin) in [#3516](https://github.com/PrefectHQ/fastmcp/pull/3516) -* Claude/review contributor guidelines by [@jlowin](https://github.com/jlowin) in [#3517](https://github.com/PrefectHQ/fastmcp/pull/3517) -* pin pydantic-monty to 0.0.8 by [@jlowin](https://github.com/jlowin) in [#3539](https://github.com/PrefectHQ/fastmcp/pull/3539) -* Support ImageContent and AudioContent in sampling handlers by [@jlowin](https://github.com/jlowin) in [#3550](https://github.com/PrefectHQ/fastmcp/pull/3550) -* Graceful degradation for multi-server proxy setup by [@jlowin](https://github.com/jlowin) in [#3546](https://github.com/PrefectHQ/fastmcp/pull/3546) -* Extract TokenCache utility, add caching to GitHubTokenVerifier by [@jlowin](https://github.com/jlowin) in [#3547](https://github.com/PrefectHQ/fastmcp/pull/3547) -* Add review-pr skill for Codex bot workflow by [@jlowin](https://github.com/jlowin) in [#3552](https://github.com/PrefectHQ/fastmcp/pull/3552) -* Add MCP message inspector to dev apps UI by [@jlowin](https://github.com/jlowin) in [#3570](https://github.com/PrefectHQ/fastmcp/pull/3570) -* Comprehensive MCP Apps docs, string CallTool resolution by [@jlowin](https://github.com/jlowin) in [#3575](https://github.com/PrefectHQ/fastmcp/pull/3575) -* Replace UUID global keys with (app_name, tool_name) registry by [@jlowin](https://github.com/jlowin) in [#3585](https://github.com/PrefectHQ/fastmcp/pull/3585) -* Route app tool calls through provider chain by [@jlowin](https://github.com/jlowin) in [#3587](https://github.com/PrefectHQ/fastmcp/pull/3587) -* Dev apps: show more/less for long tool descriptions by [@jlowin](https://github.com/jlowin) in [#3600](https://github.com/PrefectHQ/fastmcp/pull/3600) -* Apps Phase 1: docs, examples, app-only tool filtering by [@jlowin](https://github.com/jlowin) in [#3593](https://github.com/PrefectHQ/fastmcp/pull/3593) -* Forward enable_cimd to OAuthProxy in all provider subclasses by [@jlowin](https://github.com/jlowin) in [#3608](https://github.com/PrefectHQ/fastmcp/pull/3608) -* Tune too-long triage heuristic by [@jlowin](https://github.com/jlowin) in [#3610](https://github.com/PrefectHQ/fastmcp/pull/3610) -* Update ty ignore comments for 0.0.25 compatibility by [@jlowin](https://github.com/jlowin) in [#3614](https://github.com/PrefectHQ/fastmcp/pull/3614) -* Move app modules to fastmcp.apps package by [@jlowin](https://github.com/jlowin) in [#3616](https://github.com/PrefectHQ/fastmcp/pull/3616) -* Tighten too-long heuristic for design-document issues by [@jlowin](https://github.com/jlowin) in [#3620](https://github.com/PrefectHQ/fastmcp/pull/3620) -* Run MCP conformance tests by [@strawgate](https://github.com/strawgate) in [#3628](https://github.com/PrefectHQ/fastmcp/pull/3628) -* Add PrefabAppConfig for customizable Prefab tool setup by [@jlowin](https://github.com/jlowin) in [#3648](https://github.com/PrefectHQ/fastmcp/pull/3648) -* Clean error when dev apps ports are in use by [@jlowin](https://github.com/jlowin) in [#3658](https://github.com/PrefectHQ/fastmcp/pull/3658) -* Add Clerk OAuth provider by [@mostafa6765](https://github.com/mostafa6765) in [#3677](https://github.com/PrefectHQ/fastmcp/pull/3677) -* Add interactive map example with geocoding by [@jlowin](https://github.com/jlowin) in [#3702](https://github.com/PrefectHQ/fastmcp/pull/3702) -* Bump pydantic-monty to 0.0.9 by [@jlowin](https://github.com/jlowin) in [#3707](https://github.com/PrefectHQ/fastmcp/pull/3707) -* Add forward_resource flag to OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3711](https://github.com/PrefectHQ/fastmcp/pull/3711) -### Security 🔒 -* fix: enforce per-tool auth checks in sampling tool wrapper by [@jlowin](https://github.com/jlowin) in [#3494](https://github.com/PrefectHQ/fastmcp/pull/3494) -* fix: handle re.error from malformed URI templates by [@jlowin](https://github.com/jlowin) in [#3501](https://github.com/PrefectHQ/fastmcp/pull/3501) -* fix: reject empty/OIDC-only required_scopes in AzureProvider by [@jlowin](https://github.com/jlowin) in [#3503](https://github.com/PrefectHQ/fastmcp/pull/3503) -* fix: restrict $ref resolution to local refs only (SSRF/LFI) by [@jlowin](https://github.com/jlowin) in [#3502](https://github.com/PrefectHQ/fastmcp/pull/3502) -* fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) by [@jlowin](https://github.com/jlowin) in [#3507](https://github.com/PrefectHQ/fastmcp/pull/3507) -* fix: prevent path traversal in skill download by [@jlowin](https://github.com/jlowin) in [#3493](https://github.com/PrefectHQ/fastmcp/pull/3493) -* fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy by [@jlowin](https://github.com/jlowin) in [#3492](https://github.com/PrefectHQ/fastmcp/pull/3492) -* fix: remove forced follow_redirects from httpx_client_factory calls by [@jlowin](https://github.com/jlowin) in [#3496](https://github.com/PrefectHQ/fastmcp/pull/3496) -* Bump PyJWT >= 2.12.0 (CVE-2026-32597) by [@jlowin](https://github.com/jlowin) in [#3515](https://github.com/PrefectHQ/fastmcp/pull/3515) -* Drop diskcache from examples/testing_demo lockfile (CVE-2025-69872) by [@jlowin](https://github.com/jlowin) in [#3518](https://github.com/PrefectHQ/fastmcp/pull/3518) -* fix: CSRF double-submit cookie check in consent flow by [@jlowin](https://github.com/jlowin) in [#3519](https://github.com/PrefectHQ/fastmcp/pull/3519) -* fix: validate server names in install commands by [@jlowin](https://github.com/jlowin) in [#3522](https://github.com/PrefectHQ/fastmcp/pull/3522) -* fix: reject refresh tokens used as Bearer access tokens by [@jlowin](https://github.com/jlowin) in [#3524](https://github.com/PrefectHQ/fastmcp/pull/3524) -* fix: route ResourcesAsTools/PromptsAsTools through server middleware by [@jlowin](https://github.com/jlowin) in [#3495](https://github.com/PrefectHQ/fastmcp/pull/3495) -### Fixes 🐞 -* Update docs banner and fix mobile layout by [@jlowin](https://github.com/jlowin) in [#3370](https://github.com/PrefectHQ/fastmcp/pull/3370) -* Remove form-action from consent CSP, forward consent_csp_policy in providers by [@jlowin](https://github.com/jlowin) in [#3372](https://github.com/PrefectHQ/fastmcp/pull/3372) -* Fix resource templates with query params on mounted servers by [@jlowin](https://github.com/jlowin) in [#3373](https://github.com/PrefectHQ/fastmcp/pull/3373) -* Increase uv transport test timeout for CI cold starts by [@jlowin](https://github.com/jlowin) in [#3376](https://github.com/PrefectHQ/fastmcp/pull/3376) -* Fix stale catalog in CodeMode execute by [@jlowin](https://github.com/jlowin) in [#3375](https://github.com/PrefectHQ/fastmcp/pull/3375) -* Deduplicate versioned tools in CatalogTransform catalog by [@jlowin](https://github.com/jlowin) in [#3374](https://github.com/PrefectHQ/fastmcp/pull/3374) -* Fix ty 0.0.20 compatibility by [@jlowin](https://github.com/jlowin) in [#3377](https://github.com/PrefectHQ/fastmcp/pull/3377) -* Forward scopes_supported through RemoteAuthProvider subclasses by [@jlowin](https://github.com/jlowin) in [#3388](https://github.com/PrefectHQ/fastmcp/pull/3388) -* Enforce token scopes in WorkOS verifier to prevent scope bypass by [@jlowin](https://github.com/jlowin) in [#3407](https://github.com/PrefectHQ/fastmcp/pull/3407) -* Bind Discord token verification to configured client_id by [@jlowin](https://github.com/jlowin) in [#3405](https://github.com/PrefectHQ/fastmcp/pull/3405) -* Return after `McpError` in initialization middleware to prevent fallthrough by [@jlowin](https://github.com/jlowin) in [#3413](https://github.com/PrefectHQ/fastmcp/pull/3413) -* Escape client_id in OAuth consent advanced details by [@jlowin](https://github.com/jlowin) in [#3418](https://github.com/PrefectHQ/fastmcp/pull/3418) -* Bound client auto-pagination loops to prevent unbounded list fetches by [@jlowin](https://github.com/jlowin) in [#3411](https://github.com/PrefectHQ/fastmcp/pull/3411) -* Raise ValueError for invalid boolean query params in resource templates by [@jlowin](https://github.com/jlowin) in [#3434](https://github.com/PrefectHQ/fastmcp/pull/3434) -* Validate workspace path is a directory in cursor install by [@jlowin](https://github.com/jlowin) in [#3435](https://github.com/PrefectHQ/fastmcp/pull/3435) -* Validate version metadata to reject non-scalar types by [@jlowin](https://github.com/jlowin) in [#3437](https://github.com/PrefectHQ/fastmcp/pull/3437) -* Bind AWS Cognito token verification to configured app client by [@jlowin](https://github.com/jlowin) in [#3406](https://github.com/PrefectHQ/fastmcp/pull/3406) -* Avoid stale context leakage when proxying with an already‑connected ProxyClient by [@jlowin](https://github.com/jlowin) in [#3408](https://github.com/PrefectHQ/fastmcp/pull/3408) -* Prevent skills manifests from hashing files outside the skill directory by [@jlowin](https://github.com/jlowin) in [#3410](https://github.com/PrefectHQ/fastmcp/pull/3410) -* Harden fastmcp metadata parsing in proxy paths by [@jlowin](https://github.com/jlowin) in [#3412](https://github.com/PrefectHQ/fastmcp/pull/3412) -* Re-hash response caching keys to avoid persisting raw request input by [@jlowin](https://github.com/jlowin) in [#3414](https://github.com/PrefectHQ/fastmcp/pull/3414) -* Handle Windows npx detection when npx.cmd is missing by [@jlowin](https://github.com/jlowin) in [#3416](https://github.com/PrefectHQ/fastmcp/pull/3416) -* Guard OAuth callback result from post-completion overwrites by [@jlowin](https://github.com/jlowin) in [#3417](https://github.com/PrefectHQ/fastmcp/pull/3417) -* Fix tool argument rename collisions with passthrough params by [@jlowin](https://github.com/jlowin) in [#3431](https://github.com/PrefectHQ/fastmcp/pull/3431) -* Guard default progress handler against total=0 notifications by [@jlowin](https://github.com/jlowin) in [#3432](https://github.com/PrefectHQ/fastmcp/pull/3432) -* Fix get_* returning None when latest version is disabled by [@jlowin](https://github.com/jlowin) in [#3439](https://github.com/PrefectHQ/fastmcp/pull/3439) -* Fix server lifespan overlap teardown by [@jlowin](https://github.com/jlowin) in [#3415](https://github.com/PrefectHQ/fastmcp/pull/3415) -* Fix $ref output schema object detection regression by [@jlowin](https://github.com/jlowin) in [#3420](https://github.com/PrefectHQ/fastmcp/pull/3420) -* Preserve kw-only defaults when rebuilding functions for resolved annotations by [@jlowin](https://github.com/jlowin) in [#3429](https://github.com/PrefectHQ/fastmcp/pull/3429) -* Redact sensitive headers in OpenAPI provider debug logging by [@jlowin](https://github.com/jlowin) in [#3436](https://github.com/PrefectHQ/fastmcp/pull/3436) -* Fix async partial callables rejected by iscoroutinefunction by [@jlowin](https://github.com/jlowin) in [#3438](https://github.com/PrefectHQ/fastmcp/pull/3438) -* Block insecure HS* JWT verification with JWKS/public keys by [@jlowin](https://github.com/jlowin) in [#3430](https://github.com/PrefectHQ/fastmcp/pull/3430) -* Sanitize untrusted output in `fastmcp list` and `fastmcp call` by [@jlowin](https://github.com/jlowin) in [#3409](https://github.com/PrefectHQ/fastmcp/pull/3409) -* fix: propagate `version` to components in FileSystemProvider by [@martimfasantos](https://github.com/martimfasantos) in [#3458](https://github.com/PrefectHQ/fastmcp/pull/3458) -* fix: use intent-based flag for OIDC scope patch in load_access_token by [@voidborne-d](https://github.com/voidborne-d) in [#3465](https://github.com/PrefectHQ/fastmcp/pull/3465) -* Set readOnlyHint=True on ResourcesAsTools generated tools by [@jlowin](https://github.com/jlowin) in [#3476](https://github.com/PrefectHQ/fastmcp/pull/3476) -* fix: normalize Google scope shorthands and surface valid_scopes by [@jlowin](https://github.com/jlowin) in [#3477](https://github.com/PrefectHQ/fastmcp/pull/3477) -* fix: resolve ty 0.0.23 type-checking errors by [@jlowin](https://github.com/jlowin) in [#3481](https://github.com/PrefectHQ/fastmcp/pull/3481) -* fix: shield lifespan teardown from cancellation by [@jlowin](https://github.com/jlowin) in [#3480](https://github.com/PrefectHQ/fastmcp/pull/3480) -* fix: forward custom_route endpoints from mounted servers by [@voidborne-d](https://github.com/voidborne-d) in [#3462](https://github.com/PrefectHQ/fastmcp/pull/3462) -* fix: use dynamic version in CLI help text instead of hardcoded 2.0 by [@saschabuehrle](https://github.com/saschabuehrle) in [#3456](https://github.com/PrefectHQ/fastmcp/pull/3456) -* Fix Monty 0.0.8 compatibility by [@hkc5](https://github.com/hkc5) in [#3468](https://github.com/PrefectHQ/fastmcp/pull/3468) -* Fix task test teardown hanging 5s per test by [@jlowin](https://github.com/jlowin) in [#3499](https://github.com/PrefectHQ/fastmcp/pull/3499) -* fix: validate workspace path is a directory before cursor install by [@nightcityblade](https://github.com/nightcityblade) in [#3440](https://github.com/PrefectHQ/fastmcp/pull/3440) -* Treat `refresh_expires_in=0` as missing, fall back to 30-day default by [@jlowin](https://github.com/jlowin) in [#3514](https://github.com/PrefectHQ/fastmcp/pull/3514) -* fix: use raw strings for regex in pytest.raises match by [@jlowin](https://github.com/jlowin) in [#3523](https://github.com/PrefectHQ/fastmcp/pull/3523) -* fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators by [@jlowin](https://github.com/jlowin) in [#3540](https://github.com/PrefectHQ/fastmcp/pull/3540) -* fix: flaky KEY_PREFIX warning test in lowest-direct deps by [@jlowin](https://github.com/jlowin) in [#3549](https://github.com/PrefectHQ/fastmcp/pull/3549) -* fix: suppress output schema for ToolResult subclass annotations by [@jlowin](https://github.com/jlowin) in [#3548](https://github.com/PrefectHQ/fastmcp/pull/3548) -* Bump anthropic minimum to 0.48.0 by [@jlowin](https://github.com/jlowin) in [#3553](https://github.com/PrefectHQ/fastmcp/pull/3553) -* Update startup banner deploy URL to Prefect Horizon by [@zzstoatzz](https://github.com/zzstoatzz) in [#3557](https://github.com/PrefectHQ/fastmcp/pull/3557) -* fix: increase sleep duration in proxy cache tests by [@strawgate](https://github.com/strawgate) in [#3567](https://github.com/PrefectHQ/fastmcp/pull/3567) -* fix: store absolute token expiry to prevent stale expires_in on reload by [@jlowin](https://github.com/jlowin) in [#3572](https://github.com/PrefectHQ/fastmcp/pull/3572) -* fix: preserve tool properties named 'title' during schema compression by [@jlowin](https://github.com/jlowin) in [#3582](https://github.com/PrefectHQ/fastmcp/pull/3582) -* Add `encoding` parameter to `FileResource` by [@shulkx](https://github.com/shulkx) in [#3580](https://github.com/PrefectHQ/fastmcp/pull/3580) -* Transparently refresh upstream token in OAuthProxy.load_access_token() by [@jlowin](https://github.com/jlowin) in [#3584](https://github.com/PrefectHQ/fastmcp/pull/3584) -* Fix loopback redirect URI port matching per RFC 8252 §7.3 by [@radoshi](https://github.com/radoshi) in [#3589](https://github.com/PrefectHQ/fastmcp/pull/3589) -* Fix app tool routing: visibility check and middleware propagation by [@jlowin](https://github.com/jlowin) in [#3591](https://github.com/PrefectHQ/fastmcp/pull/3591) -* Fix query parameter serialization to respect OpenAPI explode setting by [@jlowin](https://github.com/jlowin) in [#3595](https://github.com/PrefectHQ/fastmcp/pull/3595) -* Fix dev apps form: union types, textarea support, JSON parsing by [@jlowin](https://github.com/jlowin) in [#3597](https://github.com/PrefectHQ/fastmcp/pull/3597) -* Respect OpenAPI content type in request body serialization by [@jlowin](https://github.com/jlowin) in [#3611](https://github.com/PrefectHQ/fastmcp/pull/3611) -* fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo by [@shigechika](https://github.com/shigechika) in [#3603](https://github.com/PrefectHQ/fastmcp/pull/3603) -* fix: resolve EntraOBOToken dependency injection through MultiAuth by [@jer805](https://github.com/jer805) in [#3609](https://github.com/PrefectHQ/fastmcp/pull/3609) -* fix: filesystem provider import machinery by [@strawgate](https://github.com/strawgate) in [#3626](https://github.com/PrefectHQ/fastmcp/pull/3626) -* fix: recover StdioTransport after subprocess exits by [@strawgate](https://github.com/strawgate) in [#3630](https://github.com/PrefectHQ/fastmcp/pull/3630) -* fix(server): preserve mounted tool task metadata by [@pandego](https://github.com/pandego) in [#3632](https://github.com/PrefectHQ/fastmcp/pull/3632) -* fix: scope deprecation warning filter to FastMCPDeprecationWarning by [@jlowin](https://github.com/jlowin) in [#3649](https://github.com/PrefectHQ/fastmcp/pull/3649) -* fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks by [@jlowin](https://github.com/jlowin) in [#3651](https://github.com/PrefectHQ/fastmcp/pull/3651) -* Fix blocking docs issues: chart imports, Select API, Rx consistency by [@jlowin](https://github.com/jlowin) in [#3652](https://github.com/PrefectHQ/fastmcp/pull/3652) -* Fix prompt caching round-trip on cache miss by [@strawgate](https://github.com/strawgate) in [#3666](https://github.com/PrefectHQ/fastmcp/pull/3666) -* fix: serialize object query params per OpenAPI style/explode rules by [@4444J99](https://github.com/4444J99) in [#3662](https://github.com/PrefectHQ/fastmcp/pull/3662) -* fix: HTTP request headers not accessible in background task workers by [@pandego](https://github.com/pandego) in [#3631](https://github.com/PrefectHQ/fastmcp/pull/3631) -* fix: restore HTTP headers in worker execution path for background tasks by [@jlowin](https://github.com/jlowin) in [#3681](https://github.com/PrefectHQ/fastmcp/pull/3681) -* fix: strip discriminator after dereferencing schemas by [@jlowin](https://github.com/jlowin) in [#3682](https://github.com/PrefectHQ/fastmcp/pull/3682) -* fix: remove stale ty:ignore directives for ty 0.0.26 by [@jlowin](https://github.com/jlowin) in [#3684](https://github.com/PrefectHQ/fastmcp/pull/3684) -* fix: dev apps log panel UX improvements by [@jlowin](https://github.com/jlowin) in [#3698](https://github.com/PrefectHQ/fastmcp/pull/3698) -* Add quiz example app, fix dev server empty string args by [@jlowin](https://github.com/jlowin) in [#3700](https://github.com/PrefectHQ/fastmcp/pull/3700) -### Docs 📚 -* Add early-development warning to Prefab docs by [@jlowin](https://github.com/jlowin) in [#3362](https://github.com/PrefectHQ/fastmcp/pull/3362) -* Add tag to docs by [@jlowin](https://github.com/jlowin) in [#3382](https://github.com/PrefectHQ/fastmcp/pull/3382) -* Add settings and environment variables reference by [@jlowin](https://github.com/jlowin) in [#3384](https://github.com/PrefectHQ/fastmcp/pull/3384) -* Add contributing guidelines and update issue/PR templates by [@jlowin](https://github.com/jlowin) in [#3485](https://github.com/PrefectHQ/fastmcp/pull/3485) -* [Documentation] Move stateless_http transport kwarg to http_app as FastMCP constructo… by [@mhallo](https://github.com/mhallo) in [#3510](https://github.com/PrefectHQ/fastmcp/pull/3510) -* Update security policy by [@jlowin](https://github.com/jlowin) in [#3521](https://github.com/PrefectHQ/fastmcp/pull/3521) -* Add release instructions to CLAUDE.md by [@jlowin](https://github.com/jlowin) in [#3583](https://github.com/PrefectHQ/fastmcp/pull/3583) -* fix(docs): correct misleading stateless_http header by [@jlowin](https://github.com/jlowin) in [#3622](https://github.com/PrefectHQ/fastmcp/pull/3622) -* Add tag to deployment pages by [@jlowin](https://github.com/jlowin) in [#3624](https://github.com/PrefectHQ/fastmcp/pull/3624) -* Docs: generative UI page, fix imports, add PrefabAppConfig by [@jlowin](https://github.com/jlowin) in [#3650](https://github.com/PrefectHQ/fastmcp/pull/3650) -* docs: improve contributor guidelines for framework contributions by [@jlowin](https://github.com/jlowin) in [#3653](https://github.com/PrefectHQ/fastmcp/pull/3653) -* Add release notes for v3.1.0, v3.1.1, and v2.14.6 by [@jlowin](https://github.com/jlowin) in [#3659](https://github.com/PrefectHQ/fastmcp/pull/3659) -* Docs: showcase hero, narrative improvements, panel closed by default by [@jlowin](https://github.com/jlowin) in [#3657](https://github.com/PrefectHQ/fastmcp/pull/3657) -* Docs: add FileTreeStore sanitization warnings and update examples by [@strawgate](https://github.com/strawgate) in [#3661](https://github.com/PrefectHQ/fastmcp/pull/3661) -* Add prefab-ui version pinning warning to docs by [@jlowin](https://github.com/jlowin) in [#3688](https://github.com/PrefectHQ/fastmcp/pull/3688) -* Reorganize apps overview TOC by [@jlowin](https://github.com/jlowin) in [#3689](https://github.com/PrefectHQ/fastmcp/pull/3689) -* Fix docs gaps in app provider pages by [@jlowin](https://github.com/jlowin) in [#3690](https://github.com/PrefectHQ/fastmcp/pull/3690) -* Polish apps docs for 3.2 release by [@jlowin](https://github.com/jlowin) in [#3693](https://github.com/PrefectHQ/fastmcp/pull/3693) -* Add apps quickstart tutorial by [@jlowin](https://github.com/jlowin) in [#3695](https://github.com/PrefectHQ/fastmcp/pull/3695) -* Improve quickstart: pie chart, interactive row selection, screenshots by [@jlowin](https://github.com/jlowin) in [#3699](https://github.com/PrefectHQ/fastmcp/pull/3699) -* Add sales dashboard and live system monitor examples, bump prefab-ui to 0.17 by [@jlowin](https://github.com/jlowin) in [#3696](https://github.com/PrefectHQ/fastmcp/pull/3696) -* Add examples gallery page by [@jlowin](https://github.com/jlowin) in [#3705](https://github.com/PrefectHQ/fastmcp/pull/3705) -* docs: note that custom routes are unauthenticated by [@jlowin](https://github.com/jlowin) in [#3706](https://github.com/PrefectHQ/fastmcp/pull/3706) -* Remove hardcoded prefab-ui version from pinning warnings by [@jlowin](https://github.com/jlowin) in [#3708](https://github.com/PrefectHQ/fastmcp/pull/3708) -### Examples & Contrib 💡 -* Block recursive self-invocation in BulkToolCaller by [@jlowin](https://github.com/jlowin) in [#3433](https://github.com/PrefectHQ/fastmcp/pull/3433) -### Dependencies 📦 -* Bump authlib from 1.6.6 to 1.6.7 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3390](https://github.com/PrefectHQ/fastmcp/pull/3390) -* Bump actions/create-github-app-token from 2 to 3 by [@dependabot](https://github.com/dependabot) in [#3511](https://github.com/PrefectHQ/fastmcp/pull/3511) -* chore(deps): bump pyasn1 from 0.6.2 to 0.6.3 in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3538](https://github.com/PrefectHQ/fastmcp/pull/3538) -* chore(deps): bump j178/prek-action from 1 to 2 by [@dependabot](https://github.com/dependabot) in [#3578](https://github.com/PrefectHQ/fastmcp/pull/3578) -* chore(deps): bump requests from 2.32.5 to 2.33.0 in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3638](https://github.com/PrefectHQ/fastmcp/pull/3638) -* chore(deps): bump cryptography from 46.0.5 to 46.0.6 in /examples/testing_demo in the uv group across 1 directory by [@dependabot](https://github.com/dependabot) in [#3685](https://github.com/PrefectHQ/fastmcp/pull/3685) -* chore(deps): bump actions/setup-node from 4 to 6 by [@dependabot](https://github.com/dependabot) in [#3691](https://github.com/PrefectHQ/fastmcp/pull/3691) - -## New Contributors -* @Sumanshu-Nankana made their first contribution in [#3380](https://github.com/PrefectHQ/fastmcp/pull/3380) -* @ericrobinson-indeed made their first contribution in [#3396](https://github.com/PrefectHQ/fastmcp/pull/3396) -* @voidborne-d made their first contribution in [#3465](https://github.com/PrefectHQ/fastmcp/pull/3465) -* @mtthidoteu made their first contribution in [#3473](https://github.com/PrefectHQ/fastmcp/pull/3473) -* @saschabuehrle made their first contribution in [#3456](https://github.com/PrefectHQ/fastmcp/pull/3456) -* @hkc5 made their first contribution in [#3468](https://github.com/PrefectHQ/fastmcp/pull/3468) -* @nightcityblade made their first contribution in [#3440](https://github.com/PrefectHQ/fastmcp/pull/3440) -* @mhallo made their first contribution in [#3510](https://github.com/PrefectHQ/fastmcp/pull/3510) -* @radoshi made their first contribution in [#3589](https://github.com/PrefectHQ/fastmcp/pull/3589) -* @shigechika made their first contribution in [#3603](https://github.com/PrefectHQ/fastmcp/pull/3603) -* @pandego made their first contribution in [#3632](https://github.com/PrefectHQ/fastmcp/pull/3632) -* @4444J99 made their first contribution in [#3662](https://github.com/PrefectHQ/fastmcp/pull/3662) -* @mostafa6765 made their first contribution in [#3677](https://github.com/PrefectHQ/fastmcp/pull/3677) - -**Full Changelog**: [v3.1.0...v3.2.0](https://github.com/PrefectHQ/fastmcp/compare/v3.1.0...v3.2.0) - -</Update> - -<Update label="v3.1.1" description="2026-03-14"> - -**[v3.1.1: 'Tis But a Patch](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.1)** - -Pins `pydantic-monty` below 0.0.8 to fix a breaking change in Monty that affects code mode. Monty 0.0.8 removed the `external_functions` constructor parameter, causing `MontySandboxProvider` to fail. This patch caps the version so existing installs work correctly. - -### Fixes 🐞 -* Pin pydantic-monty below 0.0.8 to fix code mode by [@jlowin](https://github.com/jlowin) in [#3497](https://github.com/PrefectHQ/fastmcp/pull/3497) - -**Full Changelog**: [v3.1.0...v3.1.1](https://github.com/PrefectHQ/fastmcp/compare/v3.1.0...v3.1.1) - -</Update> - -<Update label="v3.1.0" description="2026-03-03"> - -**[v3.1.0: Code to Joy](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.0)** - -FastMCP 3.1 is the Code Mode release. The 3.0 architecture introduced providers and transforms as the extensibility layer — 3.1 puts that architecture to work, shipping the most requested capability since launch: servers that can find and execute code on behalf of agents, without requiring clients to know what tools exist. - -### New Features 🎉 -* feat: Search transforms for tool discovery by [@jlowin](https://github.com/jlowin) in [#3154](https://github.com/PrefectHQ/fastmcp/pull/3154) -* Add experimental CodeMode transform by [@aaazzam](https://github.com/aaazzam) in [#3297](https://github.com/PrefectHQ/fastmcp/pull/3297) -* Add Prefab Apps integration for MCP tool UIs by [@jlowin](https://github.com/jlowin) in [#3316](https://github.com/PrefectHQ/fastmcp/pull/3316) -### Enhancements 🔧 -* Lazy-load heavy imports to reduce import time by [@jlowin](https://github.com/jlowin) in [#3295](https://github.com/PrefectHQ/fastmcp/pull/3295) -* Add http_client parameter to all token verifiers for connection pooling by [@jlowin](https://github.com/jlowin) in [#3300](https://github.com/PrefectHQ/fastmcp/pull/3300) -* Add in-memory caching for token introspection results by [@jlowin](https://github.com/jlowin) in [#3298](https://github.com/PrefectHQ/fastmcp/pull/3298) -* Add SessionStart hook to install gh CLI in cloud sessions by [@jlowin](https://github.com/jlowin) in [#3308](https://github.com/PrefectHQ/fastmcp/pull/3308) -* Fix ty 0.0.19 type errors by [@jlowin](https://github.com/jlowin) in [#3310](https://github.com/PrefectHQ/fastmcp/pull/3310) -* Code Mode: Add resource limits to MontySandboxProvider by [@jlowin](https://github.com/jlowin) in [#3326](https://github.com/PrefectHQ/fastmcp/pull/3326) -* Accept transforms as FastMCP init kwarg by [@jlowin](https://github.com/jlowin) in [#3324](https://github.com/PrefectHQ/fastmcp/pull/3324) -* Split large test files to comply with loq line limit by [@jlowin](https://github.com/jlowin) in [#3328](https://github.com/PrefectHQ/fastmcp/pull/3328) -* Add -m/--module flag to `fastmcp run` and `dev inspector` by [@dgenio](https://github.com/dgenio) in [#3331](https://github.com/PrefectHQ/fastmcp/pull/3331) -* Add search_result_serializer hook and serialize_tools_for_output_markdown by [@MagnusS0](https://github.com/MagnusS0) in [#3337](https://github.com/PrefectHQ/fastmcp/pull/3337) -* Add MultiAuth for composing multiple token verification sources by [@jlowin](https://github.com/jlowin) in [#3335](https://github.com/PrefectHQ/fastmcp/pull/3335) -* Adds PropelAuth as an AuthProvider by [@andrew-propelauth](https://github.com/andrew-propelauth) in [#3358](https://github.com/PrefectHQ/fastmcp/pull/3358) -* Replace vendored DI with uncalled-for by [@chrisguidry](https://github.com/chrisguidry) in [#3301](https://github.com/PrefectHQ/fastmcp/pull/3301) -* Decompose CodeMode into composable discovery tools by [@jlowin](https://github.com/jlowin) in [#3354](https://github.com/PrefectHQ/fastmcp/pull/3354) -* feat(contrib): auto-sync MCPMixin decorators with from_function signatures by [@AnkeshThakur](https://github.com/AnkeshThakur) in [#3323](https://github.com/PrefectHQ/fastmcp/pull/3323) -* Add Google GenAI Sampling Handler by [@strawgate](https://github.com/strawgate) in [#2977](https://github.com/PrefectHQ/fastmcp/pull/2977) -* Add ListTools, search limit, and catalog size annotation to CodeMode by [@jlowin](https://github.com/jlowin) in [#3359](https://github.com/PrefectHQ/fastmcp/pull/3359) -* Allow configuring FastMCP transport setting in the same way as other configuration by [@jvdmr](https://github.com/jvdmr) in [#1796](https://github.com/PrefectHQ/fastmcp/pull/1796) -* Add include_unversioned option to VersionFilter by [@yangbaechu](https://github.com/yangbaechu) in [#3349](https://github.com/PrefectHQ/fastmcp/pull/3349) -### Fixes 🐞 -* Fix docs banner pushing nav down by [@jlowin](https://github.com/jlowin) in [#3282](https://github.com/PrefectHQ/fastmcp/pull/3282) -* fix: Replace hardcoded TTL with DEFAULT_TTL_MS - issue #3279 by [@cedric57](https://github.com/cedric57) in [#3280](https://github.com/PrefectHQ/fastmcp/pull/3280) -* fix: stop suppressing server stderr in fastmcp call by [@jlowin](https://github.com/jlowin) in [#3283](https://github.com/PrefectHQ/fastmcp/pull/3283) -* fix: skip max_completion_tokens when maxTokens is None by [@eon01](https://github.com/eon01) in [#3284](https://github.com/PrefectHQ/fastmcp/pull/3284) -* OpenAPI: rewrite $ref under propertyNames and patternProperties in _replace_ref_with_defs; add regression test for dict[StrEnum, Model] by [@manojPal23234](https://github.com/manojPal23234) in [#3306](https://github.com/PrefectHQ/fastmcp/pull/3306) -* Remove stale add_resource() key parameter from docs by [@jlowin](https://github.com/jlowin) in [#3309](https://github.com/PrefectHQ/fastmcp/pull/3309) -* Handle AuthorizationError as exclusion in AuthMiddleware list hooks by [@yangbaechu](https://github.com/yangbaechu) in [#3338](https://github.com/PrefectHQ/fastmcp/pull/3338) -* Fix flaky OpenAPI performance test threshold by [@jlowin](https://github.com/jlowin) in [#3355](https://github.com/PrefectHQ/fastmcp/pull/3355) -* Fix flaky SSE timeout test by [@jlowin](https://github.com/jlowin) in [#3343](https://github.com/PrefectHQ/fastmcp/pull/3343) -* Remove system role references from docs by [@jlowin](https://github.com/jlowin) in [#3356](https://github.com/PrefectHQ/fastmcp/pull/3356) -* Fix session persistence across tool calls in multi-server MCPConfigTransport by [@jer805](https://github.com/jer805) in [#3330](https://github.com/PrefectHQ/fastmcp/pull/3330) -### Docs 📚 -* Add v3.0.2 release notes by [@jlowin](https://github.com/jlowin) in [#3276](https://github.com/PrefectHQ/fastmcp/pull/3276) -* Fix "FastMCP Constructor Parameters" in documentation server.mdx (Remove old parameters & Add new parameter) by [@wangyy04](https://github.com/wangyy04) in [#3317](https://github.com/PrefectHQ/fastmcp/pull/3317) -* Fix stale docs: tag filtering API and missing output_schema param by [@jlowin](https://github.com/jlowin) in [#3322](https://github.com/PrefectHQ/fastmcp/pull/3322) -* Narrate search example clients by [@jlowin](https://github.com/jlowin) in [#3321](https://github.com/PrefectHQ/fastmcp/pull/3321) -* Code Mode: Document resource limits and fix docs formatting by [@jlowin](https://github.com/jlowin) in [#3327](https://github.com/PrefectHQ/fastmcp/pull/3327) -* Add reverse proxy (nginx) section to HTTP deployment docs by [@dgenio](https://github.com/dgenio) in [#3344](https://github.com/PrefectHQ/fastmcp/pull/3344) -* Restructure docs navigation: CLI section, Composition, More by [@jlowin](https://github.com/jlowin) in [#3361](https://github.com/PrefectHQ/fastmcp/pull/3361) -### Other Changes 🦾 -* Don't advertise sampling.tools capability by default by [@jlowin](https://github.com/jlowin) in [#3334](https://github.com/PrefectHQ/fastmcp/pull/3334) - -## New Contributors -* @cedric57 made their first contribution in [#3280](https://github.com/PrefectHQ/fastmcp/pull/3280) -* @eon01 made their first contribution in [#3284](https://github.com/PrefectHQ/fastmcp/pull/3284) -* @manojPal23234 made their first contribution in [#3306](https://github.com/PrefectHQ/fastmcp/pull/3306) -* @wangyy04 made their first contribution in [#3317](https://github.com/PrefectHQ/fastmcp/pull/3317) -* @yangbaechu made their first contribution in [#3338](https://github.com/PrefectHQ/fastmcp/pull/3338) -* @andrew-propelauth made their first contribution in [#3358](https://github.com/PrefectHQ/fastmcp/pull/3358) -* @jer805 made their first contribution in [#3330](https://github.com/PrefectHQ/fastmcp/pull/3330) -* @jvdmr made their first contribution in [#1796](https://github.com/PrefectHQ/fastmcp/pull/1796) - -**Full Changelog**: [v3.0.2...v3.1.0](https://github.com/PrefectHQ/fastmcp/compare/v3.0.2...v3.1.0) - -</Update> - -<Update label="v3.0.2" description="2026-02-22"> - -**[v3.0.2: Threecovery Mode II](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.2)** - -Two community-contributed fixes: auth headers from MCP transport no longer leak through to downstream OpenAPI APIs, and background task workers now correctly receive the originating request ID. Plus a new docs example for context-aware tool factories. - -### Fixes 🐞 -* fix: prevent MCP transport auth header from leaking to downstream OpenAPI APIs by [@stakeswky](https://github.com/stakeswky) in [#3262](https://github.com/PrefectHQ/fastmcp/pull/3262) -* fix: propagate origin_request_id to background task workers by [@gfortaine](https://github.com/gfortaine) in [#3175](https://github.com/PrefectHQ/fastmcp/pull/3175) -### Docs 📚 -* Add v3.0.1 release notes by [@jlowin](https://github.com/jlowin) in [#3259](https://github.com/PrefectHQ/fastmcp/pull/3259) -* docs: add context-aware tool factory example by [@machov](https://github.com/machov) in [#3264](https://github.com/PrefectHQ/fastmcp/pull/3264) - -**Full Changelog**: [v3.0.1...v3.0.2](https://github.com/PrefectHQ/fastmcp/compare/v3.0.1...v3.0.2) - -</Update> - -<Update label="v3.0.1" description="2026-02-20"> - -**[v3.0.1: Three-covery Mode](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.1)** - -First patch after 3.0 — mostly smoothing out rough edges discovered in the wild. The big ones: middleware state that wasn't surviving the trip to tool handlers now does, `Tool.from_tool()` accepts callables again, OpenAPI schemas with circular references no longer crash discovery, and decorator overloads now return the correct types in function mode. Also adds `verify_id_token` to OIDCProxy for providers (like some Azure AD configs) that issue opaque access tokens but standard JWT id_tokens. - -### Enhancements 🔧 -* Add verify_id_token option to OIDCProxy by [@jlowin](https://github.com/jlowin) in [#3248](https://github.com/PrefectHQ/fastmcp/pull/3248) -### Fixes 🐞 -* Fix v3.0.0 changelog compare link by [@jlowin](https://github.com/jlowin) in [#3223](https://github.com/PrefectHQ/fastmcp/pull/3223) -* Fix MDX parse error in upgrade guide prompts by [@jlowin](https://github.com/jlowin) in [#3227](https://github.com/PrefectHQ/fastmcp/pull/3227) -* Fix non-serializable state lost between middleware and tools by [@jlowin](https://github.com/jlowin) in [#3234](https://github.com/PrefectHQ/fastmcp/pull/3234) -* Accept callables in Tool.from_tool() by [@jlowin](https://github.com/jlowin) in [#3235](https://github.com/PrefectHQ/fastmcp/pull/3235) -* Preserve skill metadata through provider wrapping by [@jlowin](https://github.com/jlowin) in [#3237](https://github.com/PrefectHQ/fastmcp/pull/3237) -* Fix circular reference crash in OpenAPI schemas by [@jlowin](https://github.com/jlowin) in [#3245](https://github.com/PrefectHQ/fastmcp/pull/3245) -* Fix NameError with future annotations and Context/Depends parameters by [@jlowin](https://github.com/jlowin) in [#3243](https://github.com/PrefectHQ/fastmcp/pull/3243) -* Fix ty ignore syntax in OpenAPI provider by [@jlowin](https://github.com/jlowin) in [#3253](https://github.com/PrefectHQ/fastmcp/pull/3253) -* Use max_completion_tokens instead of deprecated max_tokens in OpenAI handler by [@jlowin](https://github.com/jlowin) in [#3254](https://github.com/PrefectHQ/fastmcp/pull/3254) -* Fix ty compatibility with upgraded deps by [@jlowin](https://github.com/jlowin) in [#3257](https://github.com/PrefectHQ/fastmcp/pull/3257) -* Fix decorator overload return types for function mode by [@jlowin](https://github.com/jlowin) in [#3258](https://github.com/PrefectHQ/fastmcp/pull/3258) - - -### Docs 📚 -* Sync README with welcome.mdx, fix install count by [@jlowin](https://github.com/jlowin) in [#3224](https://github.com/PrefectHQ/fastmcp/pull/3224) -* Document dict-to-Message prompt migration in upgrade guides by [@jlowin](https://github.com/jlowin) in [#3225](https://github.com/PrefectHQ/fastmcp/pull/3225) -* Fix v2 upgrade guide: remove incorrect v1 import advice by [@jlowin](https://github.com/jlowin) in [#3226](https://github.com/PrefectHQ/fastmcp/pull/3226) -* Animated banner by [@jlowin](https://github.com/jlowin) in [#3231](https://github.com/PrefectHQ/fastmcp/pull/3231) -* Document mounted server state store isolation in upgrade guide by [@jlowin](https://github.com/jlowin) in [#3236](https://github.com/PrefectHQ/fastmcp/pull/3236) - -**Full Changelog**: [v3.0.0...v3.0.1](https://github.com/PrefectHQ/fastmcp/compare/v3.0.0...v3.0.1) - -</Update> - -<Update label="v3.0.0" description="2026-02-18"> - -**[v3.0.0: Three at Last](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0)** - -FastMCP 3.0 is stable. Two betas, two release candidates, 21 new contributors, and more than 100,000 pre-release installs later — the architecture held up, the upgrade path was smooth, and we're shipping it. - -The surface API is largely unchanged — `@mcp.tool()` still works exactly as before. What changed is everything underneath: a provider/transform architecture that makes FastMCP extensible, observable, and composable in ways v2 couldn't support. If we did our jobs right, you'll barely notice the redesign. You'll just notice that more is possible. - -This is also the release where FastMCP moves from [jlowin/fastmcp](https://github.com/jlowin/fastmcp) to [PrefectHQ/fastmcp](https://github.com/PrefectHQ/fastmcp). GitHub forwards all links, PyPI is the same, imports are the same. A major version felt like the right moment to make it official. - -### Build servers from anything - -🔌 Components no longer have to live in one file with one server. `FileSystemProvider` discovers tools from directories with hot-reload. `OpenAPIProvider` wraps REST APIs. `ProxyProvider` proxies remote MCP servers. `SkillsProvider` delivers agent skills as resources. Write your own provider for whatever source makes sense. Compose multiple providers into one server, share one across many, or chain them with **transforms** that rename, namespace, filter, version, and secure components as they flow to clients. `ResourcesAsTools` and `PromptsAsTools` expose non-tool components to tool-only clients. - -### Ship to production - -🔐 Component versioning: serve `@tool(version="2.0")` alongside older versions from one codebase. Granular authorization on individual components with async auth checks, server-wide policies via `AuthMiddleware`, and scope-based access control. OAuth gets CIMD, Static Client Registration, Azure OBO via dependency injection, JWT audience validation, and confused-deputy protections. OpenTelemetry tracing with MCP semantic conventions. Response size limiting. Background tasks with distributed Redis notification and `ctx.elicit()` relay. Security fixes include dropping `diskcache` (CVE-2025-69872) and upgrading `python-multipart` and `protobuf` for additional CVEs. - -### Adapt per session - -💾 Session state persists across requests via `ctx.set_state()` / `ctx.get_state()`. `ctx.enable_components()` and `ctx.disable_components()` let servers adapt dynamically per client — show admin tools after authentication, progressively reveal capabilities, or scope access by role. - -### Develop faster - -⚡ `--reload` auto-restarts on file changes. Standalone decorators return the original function, so decorated tools stay callable in tests and non-MCP contexts. Sync functions auto-dispatch to a threadpool. Tool timeouts, MCP-compliant pagination, composable lifespans, `PingMiddleware` for keepalive, and concurrent tool execution when the LLM returns multiple calls in one response. - -### Use FastMCP as a CLI - -🖥️ `fastmcp list` and `fastmcp call` query and invoke tools on any server from a terminal. `fastmcp discover` scans your editor configs (Claude Desktop, Cursor, Goose, Gemini CLI) and finds configured servers by name. `fastmcp generate-cli` writes a standalone typed CLI where every tool is a subcommand. `fastmcp install` registers your server with Claude Desktop, Cursor, or Goose in one command. - -### Build apps (3.1 preview) - -📱 Spec-level support for MCP Apps is in: `ui://` resource scheme, typed UI metadata via `AppConfig`, extension negotiation, and runtime detection. The full Apps experience lands in 3.1. - ---- - -If you hit 3.0 because you didn't pin your dependencies and something breaks — the [upgrade guides](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) will get you sorted. We minimized breaking changes, but a major version is a major version. - -```bash -pip install fastmcp -U -``` - -📖 [Documentation](https://gofastmcp.com) -🚀 [Upgrade from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) -🔀 [Upgrade from MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk) - -## What's Changed -### New Features 🎉 -* Refactor resource behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2611](https://github.com/PrefectHQ/fastmcp/pull/2611) -* Refactor prompt behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2610](https://github.com/PrefectHQ/fastmcp/pull/2610) -* feat: Provider abstraction for dynamic MCP components by [@jlowin](https://github.com/jlowin) in [#2622](https://github.com/PrefectHQ/fastmcp/pull/2622) -* Unify component storage in LocalProvider by [@jlowin](https://github.com/jlowin) in [#2680](https://github.com/PrefectHQ/fastmcp/pull/2680) -* Introduce ResourceResult as canonical resource return type by [@jlowin](https://github.com/jlowin) in [#2734](https://github.com/PrefectHQ/fastmcp/pull/2734) -* Introduce Message and PromptResult as canonical prompt types by [@jlowin](https://github.com/jlowin) in [#2738](https://github.com/PrefectHQ/fastmcp/pull/2738) -* Add --reload flag for auto-restart on file changes by [@jlowin](https://github.com/jlowin) in [#2816](https://github.com/PrefectHQ/fastmcp/pull/2816) -* Add FileSystemProvider for filesystem-based component discovery by [@jlowin](https://github.com/jlowin) in [#2823](https://github.com/PrefectHQ/fastmcp/pull/2823) -* Add standalone decorators and eliminate fastmcp.fs module by [@jlowin](https://github.com/jlowin) in [#2832](https://github.com/PrefectHQ/fastmcp/pull/2832) -* Add authorization checks to components and servers by [@jlowin](https://github.com/jlowin) in [#2855](https://github.com/PrefectHQ/fastmcp/pull/2855) -* Decorators return functions instead of component objects by [@jlowin](https://github.com/jlowin) in [#2856](https://github.com/PrefectHQ/fastmcp/pull/2856) -* Add transform system for modifying components in provider chains by [@jlowin](https://github.com/jlowin) in [#2836](https://github.com/PrefectHQ/fastmcp/pull/2836) -* Add OpenTelemetry tracing support by [@chrisguidry](https://github.com/chrisguidry) in [#2869](https://github.com/PrefectHQ/fastmcp/pull/2869) -* Add component versioning and VersionFilter transform by [@jlowin](https://github.com/jlowin) in [#2894](https://github.com/PrefectHQ/fastmcp/pull/2894) -* Add version discovery and calling a certain version for components by [@jlowin](https://github.com/jlowin) in [#2897](https://github.com/PrefectHQ/fastmcp/pull/2897) -* Refactor visibility to mark-based enabled system by [@jlowin](https://github.com/jlowin) in [#2912](https://github.com/PrefectHQ/fastmcp/pull/2912) -* Add session-specific visibility control via Context by [@jlowin](https://github.com/jlowin) in [#2917](https://github.com/PrefectHQ/fastmcp/pull/2917) -* Add Skills Provider for exposing agent skills as MCP resources by [@jlowin](https://github.com/jlowin) in [#2944](https://github.com/PrefectHQ/fastmcp/pull/2944) -* Add MCP Apps Phase 1 — SDK compatibility (SEP-1865) by [@jlowin](https://github.com/jlowin) in [#3009](https://github.com/PrefectHQ/fastmcp/pull/3009) -* Add `fastmcp list` and `fastmcp call` CLI commands by [@jlowin](https://github.com/jlowin) in [#3054](https://github.com/PrefectHQ/fastmcp/pull/3054) -* Add `fastmcp generate-cli` command by [@jlowin](https://github.com/jlowin) in [#3065](https://github.com/PrefectHQ/fastmcp/pull/3065) -* Add CIMD (Client ID Metadata Document) support for OAuth by [@jlowin](https://github.com/jlowin) in [#2871](https://github.com/PrefectHQ/fastmcp/pull/2871) - - -### Enhancements 🔧 -* Convert mounted servers to MountedProvider by [@jlowin](https://github.com/jlowin) in [#2635](https://github.com/PrefectHQ/fastmcp/pull/2635) -* Simplify .key as computed property by [@jlowin](https://github.com/jlowin) in [#2648](https://github.com/PrefectHQ/fastmcp/pull/2648) -* Refactor MountedProvider into FastMCPProvider + TransformingProvider by [@jlowin](https://github.com/jlowin) in [#2653](https://github.com/PrefectHQ/fastmcp/pull/2653) -* Enable background task support for custom component subclasses by [@jlowin](https://github.com/jlowin) in [#2657](https://github.com/PrefectHQ/fastmcp/pull/2657) -* Use CreateTaskResult for background task creation by [@jlowin](https://github.com/jlowin) in [#2660](https://github.com/PrefectHQ/fastmcp/pull/2660) -* Refactor provider execution: components own their execution by [@jlowin](https://github.com/jlowin) in [#2663](https://github.com/PrefectHQ/fastmcp/pull/2663) -* Add supports_tasks() method to replace string mode checks by [@jlowin](https://github.com/jlowin) in [#2664](https://github.com/PrefectHQ/fastmcp/pull/2664) -* Replace type: ignore[attr-defined] with isinstance assertions in tests by [@jlowin](https://github.com/jlowin) in [#2665](https://github.com/PrefectHQ/fastmcp/pull/2665) -* Add poll_interval to TaskConfig by [@jlowin](https://github.com/jlowin) in [#2666](https://github.com/PrefectHQ/fastmcp/pull/2666) -* Refactor task module: rename protocol.py to requests.py and reduce redundancy by [@jlowin](https://github.com/jlowin) in [#2667](https://github.com/PrefectHQ/fastmcp/pull/2667) -* Refactor FastMCPProxy into ProxyProvider by [@jlowin](https://github.com/jlowin) in [#2669](https://github.com/PrefectHQ/fastmcp/pull/2669) -* Move OpenAPI to providers/openapi submodule by [@jlowin](https://github.com/jlowin) in [#2672](https://github.com/PrefectHQ/fastmcp/pull/2672) -* Use ergonomic provider initialization pattern by [@jlowin](https://github.com/jlowin) in [#2675](https://github.com/PrefectHQ/fastmcp/pull/2675) -* Fix ty 0.0.5 type errors by [@jlowin](https://github.com/jlowin) in [#2676](https://github.com/PrefectHQ/fastmcp/pull/2676) -* Remove execution methods from Provider base class by [@jlowin](https://github.com/jlowin) in [#2681](https://github.com/PrefectHQ/fastmcp/pull/2681) -* Add type-prefixed keys for globally unique component identification by [@jlowin](https://github.com/jlowin) in [#2704](https://github.com/PrefectHQ/fastmcp/pull/2704) -* Consolidate notification system with unified API by [@jlowin](https://github.com/jlowin) in [#2710](https://github.com/PrefectHQ/fastmcp/pull/2710) -* Parallelize provider operations by [@jlowin](https://github.com/jlowin) in [#2716](https://github.com/PrefectHQ/fastmcp/pull/2716) -* Consolidate get_* and _list_* methods into single API by [@jlowin](https://github.com/jlowin) in [#2719](https://github.com/PrefectHQ/fastmcp/pull/2719) -* Consolidate execution method chains into single public API by [@jlowin](https://github.com/jlowin) in [#2728](https://github.com/PrefectHQ/fastmcp/pull/2728) -* Parallelize list_* calls in Provider.get_tasks() by [@jlowin](https://github.com/jlowin) in [#2731](https://github.com/PrefectHQ/fastmcp/pull/2731) -* Consistent decorator-based MCP handler registration by [@jlowin](https://github.com/jlowin) in [#2732](https://github.com/PrefectHQ/fastmcp/pull/2732) -* Make ToolResult a BaseModel for serialization support by [@jlowin](https://github.com/jlowin) in [#2736](https://github.com/PrefectHQ/fastmcp/pull/2736) -* Align prompt handler with resource pattern by [@jlowin](https://github.com/jlowin) in [#2740](https://github.com/PrefectHQ/fastmcp/pull/2740) -* Update classes to inherit from FastMCPBaseModel instead of BaseModel by [@jlowin](https://github.com/jlowin) in [#2739](https://github.com/PrefectHQ/fastmcp/pull/2739) -* Add explicit task_meta parameter to FastMCP.call_tool() by [@jlowin](https://github.com/jlowin) in [#2749](https://github.com/PrefectHQ/fastmcp/pull/2749) -* Add task_meta parameter to read_resource() for explicit task control by [@jlowin](https://github.com/jlowin) in [#2750](https://github.com/PrefectHQ/fastmcp/pull/2750) -* Add task_meta to prompts and centralize fn_key enrichment by [@jlowin](https://github.com/jlowin) in [#2751](https://github.com/PrefectHQ/fastmcp/pull/2751) -* Remove unused include_tags/exclude_tags settings by [@jlowin](https://github.com/jlowin) in [#2756](https://github.com/PrefectHQ/fastmcp/pull/2756) -* Parallelize provider access when executing components by [@jlowin](https://github.com/jlowin) in [#2744](https://github.com/PrefectHQ/fastmcp/pull/2744) -* Deprecate tool_serializer parameter by [@jlowin](https://github.com/jlowin) in [#2753](https://github.com/PrefectHQ/fastmcp/pull/2753) -* Feature/supabase custom auth route by [@EloiZalczer](https://github.com/EloiZalczer) in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) -* Remove deprecated WSTransport by [@jlowin](https://github.com/jlowin) in [#2826](https://github.com/PrefectHQ/fastmcp/pull/2826) -* Add composable lifespans by [@jlowin](https://github.com/jlowin) in [#2828](https://github.com/PrefectHQ/fastmcp/pull/2828) -* Replace FastMCP.as_proxy() with create_proxy() function by [@jlowin](https://github.com/jlowin) in [#2829](https://github.com/PrefectHQ/fastmcp/pull/2829) -* Add PingMiddleware for keepalive connections by [@jlowin](https://github.com/jlowin) in [#2838](https://github.com/PrefectHQ/fastmcp/pull/2838) -* Run sync tools/resources/prompts in threadpool automatically by [@jlowin](https://github.com/jlowin) in [#2865](https://github.com/PrefectHQ/fastmcp/pull/2865) -* Add timeout parameter for tool foreground execution by [@jlowin](https://github.com/jlowin) in [#2872](https://github.com/PrefectHQ/fastmcp/pull/2872) -* Adopt OpenTelemetry MCP semantic conventions by [@chrisguidry](https://github.com/chrisguidry) in [#2886](https://github.com/PrefectHQ/fastmcp/pull/2886) -* Add client_secret_post authentication to IntrospectionTokenVerifier by [@shulkx](https://github.com/shulkx) in [#2884](https://github.com/PrefectHQ/fastmcp/pull/2884) -* Add enable_rich_logging setting to disable rich formatting by [@strawgate](https://github.com/strawgate) in [#2893](https://github.com/PrefectHQ/fastmcp/pull/2893) -* Rename _fastmcp metadata namespace to fastmcp and make non-optional by [@jlowin](https://github.com/jlowin) in [#2895](https://github.com/PrefectHQ/fastmcp/pull/2895) -* Refactor FastMCP to inherit from Provider by [@jlowin](https://github.com/jlowin) in [#2901](https://github.com/PrefectHQ/fastmcp/pull/2901) -* Swap public/private method naming in Provider by [@jlowin](https://github.com/jlowin) in [#2902](https://github.com/PrefectHQ/fastmcp/pull/2902) -* Add MCP-compliant pagination support by [@jlowin](https://github.com/jlowin) in [#2903](https://github.com/PrefectHQ/fastmcp/pull/2903) -* Support VersionSpec in enable/disable for range-based filtering by [@jlowin](https://github.com/jlowin) in [#2914](https://github.com/PrefectHQ/fastmcp/pull/2914) -* Immutable transform wrapping for providers by [@jlowin](https://github.com/jlowin) in [#2913](https://github.com/PrefectHQ/fastmcp/pull/2913) -* Unify discovery API: deduplicate at protocol layer only by [@jlowin](https://github.com/jlowin) in [#2919](https://github.com/PrefectHQ/fastmcp/pull/2919) -* Add ResourcesAsTools transform by [@jlowin](https://github.com/jlowin) in [#2943](https://github.com/PrefectHQ/fastmcp/pull/2943) -* Add PromptsAsTools transform by [@jlowin](https://github.com/jlowin) in [#2946](https://github.com/PrefectHQ/fastmcp/pull/2946) -* Rename Enabled transform to Visibility by [@jlowin](https://github.com/jlowin) in [#2950](https://github.com/PrefectHQ/fastmcp/pull/2950) -* feat: option to add upstream claims to the FastMCP proxy JWT by [@JonasKs](https://github.com/JonasKs) in [#2997](https://github.com/PrefectHQ/fastmcp/pull/2997) -* fix: automatically include offline_access as a scope in the Azure provider by [@JonasKs](https://github.com/JonasKs) in [#3001](https://github.com/PrefectHQ/fastmcp/pull/3001) -* feat: expand --reload to watch frontend file types by [@jlowin](https://github.com/jlowin) in [#3028](https://github.com/PrefectHQ/fastmcp/pull/3028) -* Add `fastmcp install stdio` command by [@jlowin](https://github.com/jlowin) in [#3032](https://github.com/PrefectHQ/fastmcp/pull/3032) -* feat: Goose integration + dedicated install command by [@jlowin](https://github.com/jlowin) in [#3040](https://github.com/PrefectHQ/fastmcp/pull/3040) -* Add `fastmcp discover` and name-based server resolution by [@jlowin](https://github.com/jlowin) in [#3055](https://github.com/PrefectHQ/fastmcp/pull/3055) -* feat(context): Add background task support for Context by [@gfortaine](https://github.com/gfortaine) in [#2905](https://github.com/PrefectHQ/fastmcp/pull/2905) -* Add server version to banner by [@richardkmichael](https://github.com/richardkmichael) in [#3076](https://github.com/PrefectHQ/fastmcp/pull/3076) -* Add @handle_tool_errors decorator for standardized error handling by [@dgenio](https://github.com/dgenio) in [#2885](https://github.com/PrefectHQ/fastmcp/pull/2885) -* Add ResponseLimitingMiddleware for tool response size control by [@dgenio](https://github.com/dgenio) in [#3072](https://github.com/PrefectHQ/fastmcp/pull/3072) -* Infer MIME types from OpenAPI response definitions by [@jlowin](https://github.com/jlowin) in [#3101](https://github.com/PrefectHQ/fastmcp/pull/3101) -* Remove require_auth in favor of scope-based authorization by [@jlowin](https://github.com/jlowin) in [#3103](https://github.com/PrefectHQ/fastmcp/pull/3103) -* generate-cli: auto-generate SKILL.md agent skill by [@jlowin](https://github.com/jlowin) in [#3115](https://github.com/PrefectHQ/fastmcp/pull/3115) -* Add Azure OBO dependencies, auth token injection, and documentation by [@jlowin](https://github.com/jlowin) in [#2918](https://github.com/PrefectHQ/fastmcp/pull/2918) -* feat: add Static Client Registration by [@martimfasantos](https://github.com/martimfasantos) in [#3086](https://github.com/PrefectHQ/fastmcp/pull/3086) -* Add concurrent tool execution with sequential flag by [@strawgate](https://github.com/strawgate) in [#3022](https://github.com/PrefectHQ/fastmcp/pull/3022) -* Add validate_output option for OpenAPI tools by [@jlowin](https://github.com/jlowin) in [#3134](https://github.com/PrefectHQ/fastmcp/pull/3134) -* Relay task elicitation through standard MCP protocol by [@chrisguidry](https://github.com/chrisguidry) in [#3136](https://github.com/PrefectHQ/fastmcp/pull/3136) -* Support async auth checks by [@jlowin](https://github.com/jlowin) in [#3152](https://github.com/PrefectHQ/fastmcp/pull/3152) -* Make $ref dereferencing optional via FastMCP(dereference_refs=...) by [@jlowin](https://github.com/jlowin) in [#3151](https://github.com/PrefectHQ/fastmcp/pull/3151) -* Expose local_provider property, deprecate FastMCP.remove_tool() by [@jlowin](https://github.com/jlowin) in [#3155](https://github.com/PrefectHQ/fastmcp/pull/3155) -* Add helpers for converting FunctionTool and TransformedTool to SamplingTool by [@strawgate](https://github.com/strawgate) in [#3062](https://github.com/PrefectHQ/fastmcp/pull/3062) -### Fixes 🐞 -* Let FastMCPError propagate from dependencies by [@chrisguidry](https://github.com/chrisguidry) in [#2646](https://github.com/PrefectHQ/fastmcp/pull/2646) -* Fix task execution for tools with custom names by [@chrisguidry](https://github.com/chrisguidry) in [#2645](https://github.com/PrefectHQ/fastmcp/pull/2645) -* fix: check the cause of the tool error by [@rjolaverria](https://github.com/rjolaverria) in [#2674](https://github.com/PrefectHQ/fastmcp/pull/2674) -* Fix uvicorn 0.39+ test timeouts and FastMCPError propagation by [@jlowin](https://github.com/jlowin) in [#2699](https://github.com/PrefectHQ/fastmcp/pull/2699) -* Fix: resolve root-level $ref in outputSchema for MCP spec compliance by [@majiayu000](https://github.com/majiayu000) in [#2720](https://github.com/PrefectHQ/fastmcp/pull/2720) -* Fix Proxy provider to return all resource contents by [@jlowin](https://github.com/jlowin) in [#2742](https://github.com/PrefectHQ/fastmcp/pull/2742) -* fix: Client OAuth async_auth_flow() method causing MCP-SDK lock error by [@lgndluke](https://github.com/lgndluke) in [#2644](https://github.com/PrefectHQ/fastmcp/pull/2644) -* Fix rate limit detection during teardown phase by [@jlowin](https://github.com/jlowin) in [#2757](https://github.com/PrefectHQ/fastmcp/pull/2757) -* Fix OAuth Proxy resource parameter validation by [@jlowin](https://github.com/jlowin) in [#2764](https://github.com/PrefectHQ/fastmcp/pull/2764) -* Fix `openapi_version` check so 3.1 is included by [@deeleeramone](https://github.com/deeleeramone) in [#2768](https://github.com/PrefectHQ/fastmcp/pull/2768) -* Fix base_url fallback when url is not set by [@bhbs](https://github.com/bhbs) in [#2776](https://github.com/PrefectHQ/fastmcp/pull/2776) -* Lazy import DiskStore to avoid sqlite3 dependency on import by [@jlowin](https://github.com/jlowin) in [#2784](https://github.com/PrefectHQ/fastmcp/pull/2784) -* Fix OAuth token storage TTL calculation by [@jlowin](https://github.com/jlowin) in [#2796](https://github.com/PrefectHQ/fastmcp/pull/2796) -* Fix client hanging on HTTP 4xx/5xx errors by [@jlowin](https://github.com/jlowin) in [#2803](https://github.com/PrefectHQ/fastmcp/pull/2803) -* Fix keep_alive passthrough in StdioMCPServer.to_transport() by [@jlowin](https://github.com/jlowin) in [#2791](https://github.com/PrefectHQ/fastmcp/pull/2791) -* Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2808](https://github.com/PrefectHQ/fastmcp/pull/2808) -* Fix timeout not propagating to proxy clients in multi-server MCPConfig by [@jlowin](https://github.com/jlowin) in [#2809](https://github.com/PrefectHQ/fastmcp/pull/2809) -* Fix ContextVar propagation for ASGI-mounted servers with tasks by [@chrisguidry](https://github.com/chrisguidry) in [#2844](https://github.com/PrefectHQ/fastmcp/pull/2844) -* Fix HTTP transport timeout defaulting to 5 seconds by [@jlowin](https://github.com/jlowin) in [#2849](https://github.com/PrefectHQ/fastmcp/pull/2849) -* Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2875](https://github.com/PrefectHQ/fastmcp/pull/2875) -* fix: broaden combine_lifespans type to accept Mapping return types by [@aminsamir45](https://github.com/aminsamir45) in [#3005](https://github.com/PrefectHQ/fastmcp/pull/3005) -* fix: correctly send resource when exchanging code for upstream by [@JonasKs](https://github.com/JonasKs) in [#3013](https://github.com/PrefectHQ/fastmcp/pull/3013) -* chore: upgrade python-multipart to 0.0.22 (CVE-2026-24486) by [@jlowin](https://github.com/jlowin) in [#3042](https://github.com/PrefectHQ/fastmcp/pull/3042) -* chore: upgrade protobuf to 6.33.5 (CVE-2026-0994) by [@jlowin](https://github.com/jlowin) in [#3043](https://github.com/PrefectHQ/fastmcp/pull/3043) -* fix: use MCP spec error code -32002 for resource not found by [@jlowin](https://github.com/jlowin) in [#3041](https://github.com/PrefectHQ/fastmcp/pull/3041) -* Fix tool_choice reset for structured output sampling by [@strawgate](https://github.com/strawgate) in [#3014](https://github.com/PrefectHQ/fastmcp/pull/3014) -* fix: Preserve metadata in FastMCPProvider component wrappers by [@NeelayS](https://github.com/NeelayS) in [#3057](https://github.com/PrefectHQ/fastmcp/pull/3057) -* fix: enforce redirect URI validation when allowed_client_redirect_uris is supplied by [@nathanwelsh8](https://github.com/nathanwelsh8) in [#3066](https://github.com/PrefectHQ/fastmcp/pull/3066) -* Fix --reload port conflict when using explicit port by [@jlowin](https://github.com/jlowin) in [#3070](https://github.com/PrefectHQ/fastmcp/pull/3070) -* Fix compress_schema to preserve additionalProperties: false by [@jlowin](https://github.com/jlowin) in [#3102](https://github.com/PrefectHQ/fastmcp/pull/3102) -* Fix CIMD redirect allowlist bypass and cache revalidation by [@jlowin](https://github.com/jlowin) in [#3098](https://github.com/PrefectHQ/fastmcp/pull/3098) -* Fix session visibility marks leaking across sessions by [@jlowin](https://github.com/jlowin) in [#3132](https://github.com/PrefectHQ/fastmcp/pull/3132) -* Fix unhandled exceptions in OpenAPI POST tool calls by [@jlowin](https://github.com/jlowin) in [#3133](https://github.com/PrefectHQ/fastmcp/pull/3133) -* feat: distributed notification queue + BLPOP elicitation for background tasks by [@gfortaine](https://github.com/gfortaine) in [#2906](https://github.com/PrefectHQ/fastmcp/pull/2906) -* fix: snapshot access token for background tasks by [@gfortaine](https://github.com/gfortaine) in [#3138](https://github.com/PrefectHQ/fastmcp/pull/3138) -* fix: guard client pagination loops against misbehaving servers by [@jlowin](https://github.com/jlowin) in [#3167](https://github.com/PrefectHQ/fastmcp/pull/3167) -* Support non-serializable values in Context.set_state by [@jlowin](https://github.com/jlowin) in [#3171](https://github.com/PrefectHQ/fastmcp/pull/3171) -* Fix stale request context in StatefulProxyClient handlers by [@jlowin](https://github.com/jlowin) in [#3172](https://github.com/PrefectHQ/fastmcp/pull/3172) -* Drop diskcache dependency (CVE-2025-69872) by [@jlowin](https://github.com/jlowin) in [#3185](https://github.com/PrefectHQ/fastmcp/pull/3185) -* Fix confused deputy attack via consent binding cookie by [@jlowin](https://github.com/jlowin) in [#3201](https://github.com/PrefectHQ/fastmcp/pull/3201) -* Add JWT audience validation and RFC 8707 warnings to auth providers by [@jlowin](https://github.com/jlowin) in [#3204](https://github.com/PrefectHQ/fastmcp/pull/3204) -* Cache OBO credentials on AzureProvider for token reuse by [@jlowin](https://github.com/jlowin) in [#3212](https://github.com/PrefectHQ/fastmcp/pull/3212) -* Fix invalid uv add command in upgrade guide by [@jlowin](https://github.com/jlowin) in [#3217](https://github.com/PrefectHQ/fastmcp/pull/3217) -* Use standard traceparent/tracestate keys per OTel MCP semconv by [@chrisguidry](https://github.com/chrisguidry) in [#3221](https://github.com/PrefectHQ/fastmcp/pull/3221) -### Breaking Changes 🛫 -* Add VisibilityFilter for hierarchical enable/disable by [@jlowin](https://github.com/jlowin) in [#2708](https://github.com/PrefectHQ/fastmcp/pull/2708) -* Remove automatic environment variable loading from auth providers by [@jlowin](https://github.com/jlowin) in [#2752](https://github.com/PrefectHQ/fastmcp/pull/2752) -* Make pydocket optional and unify DI systems by [@jlowin](https://github.com/jlowin) in [#2835](https://github.com/PrefectHQ/fastmcp/pull/2835) -* Add session-scoped state persistence by [@jlowin](https://github.com/jlowin) in [#2873](https://github.com/PrefectHQ/fastmcp/pull/2873) -* Rename ui= to app= and consolidate ToolUI/ResourceUI into AppConfig by [@jlowin](https://github.com/jlowin) in [#3117](https://github.com/PrefectHQ/fastmcp/pull/3117) -* Remove deprecated FastMCP() constructor kwargs by [@jlowin](https://github.com/jlowin) in [#3148](https://github.com/PrefectHQ/fastmcp/pull/3148) -* Move `fastmcp dev` to `fastmcp dev inspector` by [@jlowin](https://github.com/jlowin) in [#3188](https://github.com/PrefectHQ/fastmcp/pull/3188) - -## New Contributors -* [@ivanbelenky](https://github.com/ivanbelenky) made their first contribution in [#2656](https://github.com/PrefectHQ/fastmcp/pull/2656) -* [@rjolaverria](https://github.com/rjolaverria) made their first contribution in [#2674](https://github.com/PrefectHQ/fastmcp/pull/2674) -* [@mgoldsborough](https://github.com/mgoldsborough) made their first contribution in [#2701](https://github.com/PrefectHQ/fastmcp/pull/2701) -* [@Ashif4354](https://github.com/Ashif4354) made their first contribution in [#2707](https://github.com/PrefectHQ/fastmcp/pull/2707) -* [@majiayu000](https://github.com/majiayu000) made their first contribution in [#2720](https://github.com/PrefectHQ/fastmcp/pull/2720) -* [@lgndluke](https://github.com/lgndluke) made their first contribution in [#2644](https://github.com/PrefectHQ/fastmcp/pull/2644) -* [@EloiZalczer](https://github.com/EloiZalczer) made their first contribution in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) -* [@deeleeramone](https://github.com/deeleeramone) made their first contribution in [#2768](https://github.com/PrefectHQ/fastmcp/pull/2768) -* [@shea-parkes](https://github.com/shea-parkes) made their first contribution in [#2781](https://github.com/PrefectHQ/fastmcp/pull/2781) -* [@bryankthompson](https://github.com/bryankthompson) made their first contribution in [#2777](https://github.com/PrefectHQ/fastmcp/pull/2777) -* [@bhbs](https://github.com/bhbs) made their first contribution in [#2776](https://github.com/PrefectHQ/fastmcp/pull/2776) -* [@shulkx](https://github.com/shulkx) made their first contribution in [#2884](https://github.com/PrefectHQ/fastmcp/pull/2884) -* [@abhijeethp](https://github.com/abhijeethp) made their first contribution in [#2967](https://github.com/PrefectHQ/fastmcp/pull/2967) -* [@aminsamir45](https://github.com/aminsamir45) made their first contribution in [#3005](https://github.com/PrefectHQ/fastmcp/pull/3005) -* [@JonasKs](https://github.com/JonasKs) made their first contribution in [#2997](https://github.com/PrefectHQ/fastmcp/pull/2997) -* [@NeelayS](https://github.com/NeelayS) made their first contribution in [#3057](https://github.com/PrefectHQ/fastmcp/pull/3057) -* [@gfortaine](https://github.com/gfortaine) made their first contribution in [#2905](https://github.com/PrefectHQ/fastmcp/pull/2905) -* [@nathanwelsh8](https://github.com/nathanwelsh8) made their first contribution in [#3066](https://github.com/PrefectHQ/fastmcp/pull/3066) -* [@dgenio](https://github.com/dgenio) made their first contribution in [#2885](https://github.com/PrefectHQ/fastmcp/pull/2885) -* [@martimfasantos](https://github.com/martimfasantos) made their first contribution in [#3086](https://github.com/PrefectHQ/fastmcp/pull/3086) -* [@jfBiswajit](https://github.com/jfBiswajit) made their first contribution in [#3193](https://github.com/PrefectHQ/fastmcp/pull/3193) - -**Full Changelog**: https://github.com/PrefectHQ/fastmcp/compare/v2.14.5...v3.0.0 - -</Update> - -<Update label="v3.0.0rc1" description="2026-02-12"> - -**[v3.0.0rc1: RC-ing is Believing](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0rc1)** - -FastMCP 3 RC1 means we believe the API is stable. Beta 2 drew a wave of real-world adoption — production deployments, migration reports, integration testing — and the feedback overwhelmingly confirmed that the architecture works. This release closes gaps that surfaced under load: auth flows that needed to be async, background tasks that needed reliable notification delivery, and APIs still carrying beta-era naming. If nothing unexpected surfaces, this is what 3.0.0 looks like. - -🚨 **Breaking Changes** — The `ui=` parameter is now `app=` with a unified `AppConfig` class (matching the feature's actual name), and 16 `FastMCP()` constructor kwargs have finally been removed. If you've been ignoring months of deprecation warnings, you'll get a `TypeError` with specific migration instructions. - -🔐 **Auth Improvements** — Three changes that together round out FastMCP's auth story for production. `auth=` checks can now be `async`, so you can hit databases or external services during authorization — previously, passing an async function silently passed because the unawaited coroutine was truthy. Static Client Registration lets clients provide a pre-registered `client_id`/`client_secret` directly, bypassing DCR for servers that don't support it. And Azure OBO flows are now declarative via dependency injection: - -```python -from fastmcp.server.auth.providers.azure import EntraOBOToken - -@mcp.tool() -async def get_emails( - graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), -): - # OBO exchange already happened — just use the token - ... -``` - -⚡ **Concurrent Sampling** — When an LLM returns multiple tool calls in a single response, `context.sample()` can now execute them in parallel. Opt in with `tool_concurrency=0` for unlimited parallelism, or set a bound. Tools that aren't safe to parallelize can declare `sequential=True`. - -📡 **Background Task Notifications** — Background tasks now reliably push progress updates and elicit user input through the standard MCP protocol. A distributed Redis queue replaces polling (7,200 round-trips/hour → one blocking call), and `ctx.elicit()` in background tasks automatically relays through the client's standard `elicitation_handler`. - -✅ **OpenAPI Output Validation** — When backends don't conform to their own OpenAPI schemas, the MCP SDK rejects the response and the tool fails. `validate_output=False` disables strict schema checking while still passing structured JSON to clients — a necessary escape hatch for imperfect APIs. - -## What's Changed -### Enhancements 🔧 -* generate-cli: auto-generate SKILL.md agent skill by [@jlowin](https://github.com/jlowin) in [#3115](https://github.com/PrefectHQ/fastmcp/pull/3115) -* Scope Martian triage to bug-labeled issues for jlowin by [@jlowin](https://github.com/jlowin) in [#3124](https://github.com/PrefectHQ/fastmcp/pull/3124) -* Add Azure OBO dependencies, auth token injection, and documentation by [@jlowin](https://github.com/jlowin) in [#2918](https://github.com/PrefectHQ/fastmcp/pull/2918) -* feat: add Static Client Registration (#3085) by [@martimfasantos](https://github.com/martimfasantos) in [#3086](https://github.com/PrefectHQ/fastmcp/pull/3086) -* Add concurrent tool execution with sequential flag by [@strawgate](https://github.com/strawgate) in [#3022](https://github.com/PrefectHQ/fastmcp/pull/3022) -* Add validate_output option for OpenAPI tools by [@jlowin](https://github.com/jlowin) in [#3134](https://github.com/PrefectHQ/fastmcp/pull/3134) -* Relay task elicitation through standard MCP protocol by [@chrisguidry](https://github.com/chrisguidry) in [#3136](https://github.com/PrefectHQ/fastmcp/pull/3136) -* Bump py-key-value-aio to `>=0.4.0,<0.5.0` by [@strawgate](https://github.com/strawgate) in [#3143](https://github.com/PrefectHQ/fastmcp/pull/3143) -* Support async auth checks by [@jlowin](https://github.com/jlowin) in [#3152](https://github.com/PrefectHQ/fastmcp/pull/3152) -* Make $ref dereferencing optional via FastMCP(dereference_refs=...) by [@jlowin](https://github.com/jlowin) in [#3151](https://github.com/PrefectHQ/fastmcp/pull/3151) -* Expose local_provider property, deprecate FastMCP.remove_tool() by [@jlowin](https://github.com/jlowin) in [#3155](https://github.com/PrefectHQ/fastmcp/pull/3155) -* Add helpers for converting FunctionTool and TransformedTool to SamplingTool by [@strawgate](https://github.com/strawgate) in [#3062](https://github.com/PrefectHQ/fastmcp/pull/3062) -* Updates to github actions / workflows for claude by [@strawgate](https://github.com/strawgate) in [#3157](https://github.com/PrefectHQ/fastmcp/pull/3157) -### Fixes 🐞 -* Updated deprecation URL for V3 by [@SrzStephen](https://github.com/SrzStephen) in [#3108](https://github.com/PrefectHQ/fastmcp/pull/3108) -* Fix Windows test timeouts in OAuth proxy provider tests by [@strawgate](https://github.com/strawgate) in [#3123](https://github.com/PrefectHQ/fastmcp/pull/3123) -* Fix session visibility marks leaking across sessions by [@jlowin](https://github.com/jlowin) in [#3132](https://github.com/PrefectHQ/fastmcp/pull/3132) -* Fix unhandled exceptions in OpenAPI POST tool calls by [@jlowin](https://github.com/jlowin) in [#3133](https://github.com/PrefectHQ/fastmcp/pull/3133) -* feat: distributed notification queue + BLPOP elicitation for background tasks by [@gfortaine](https://github.com/gfortaine) in [#2906](https://github.com/PrefectHQ/fastmcp/pull/2906) -* fix: snapshot access token for background tasks (#3095) by [@gfortaine](https://github.com/gfortaine) in [#3138](https://github.com/PrefectHQ/fastmcp/pull/3138) -* Stop duplicating path parameter descriptions into tool prose by [@jlowin](https://github.com/jlowin) in [#3149](https://github.com/PrefectHQ/fastmcp/pull/3149) -* fix: guard client pagination loops against misbehaving servers by [@jlowin](https://github.com/jlowin) in [#3167](https://github.com/PrefectHQ/fastmcp/pull/3167) -* Fix stale get_* references in docs and examples by [@jlowin](https://github.com/jlowin) in [#3168](https://github.com/PrefectHQ/fastmcp/pull/3168) -* Support non-serializable values in Context.set_state by [@jlowin](https://github.com/jlowin) in [#3171](https://github.com/PrefectHQ/fastmcp/pull/3171) -* Fix stale request context in StatefulProxyClient handlers by [@jlowin](https://github.com/jlowin) in [#3172](https://github.com/PrefectHQ/fastmcp/pull/3172) -### Breaking Changes 🛫 -* Rename ui= to app= and consolidate ToolUI/ResourceUI into AppConfig by [@jlowin](https://github.com/jlowin) in [#3117](https://github.com/PrefectHQ/fastmcp/pull/3117) -* Remove deprecated FastMCP() constructor kwargs by [@jlowin](https://github.com/jlowin) in [#3148](https://github.com/PrefectHQ/fastmcp/pull/3148) -### Docs 📚 -* Update docs to reference beta 2 by [@jlowin](https://github.com/jlowin) in [#3112](https://github.com/PrefectHQ/fastmcp/pull/3112) -* docs: add pre-registered OAuth clients to v3-features by [@jlowin](https://github.com/jlowin) in [#3129](https://github.com/PrefectHQ/fastmcp/pull/3129) -### Dependencies 📦 -* chore(deps): bump cryptography from 46.0.3 to 46.0.5 in /examples/testing_demo in the uv group across 1 directory by @dependabot in [#3140](https://github.com/PrefectHQ/fastmcp/pull/3140) -### Other Changes 🦾 -* docs: add v3.0.0rc1 features to v3-features tracking by [@jlowin](https://github.com/jlowin) in [#3145](https://github.com/PrefectHQ/fastmcp/pull/3145) -* docs: remove nonexistent MSALApp from rc1 notes by [@jlowin](https://github.com/jlowin) in [#3146](https://github.com/PrefectHQ/fastmcp/pull/3146) - -## New Contributors -* [@martimfasantos](https://github.com/martimfasantos) made their first contribution in [#3086](https://github.com/PrefectHQ/fastmcp/pull/3086) - -**Full Changelog**: https://github.com/PrefectHQ/fastmcp/compare/v3.0.0b2...v3.0.0rc1 - -</Update> - -<Update label="v3.0.0b2" description="2026-02-07"> - -**[v3.0.0b2: 2 Fast 2 Beta](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0b2)** - -FastMCP 3 Beta 2 reflects the huge number of people that kicked the tires on Beta 1. Seven new contributors landed changes in this release, and early migration reports went smoother than expected, including teams on Prefect Horizon upgrading from v2. Most of Beta 2 is refinement: fixing what people found, filling gaps from real usage, hardening edges. But a few new features did land along the way. - -🖥️ **Client CLI** — `fastmcp list`, `fastmcp call`, `fastmcp discover`, and `fastmcp generate-cli` turn any MCP server into something you can poke at from a terminal. Discover servers configured in Claude Desktop, Cursor, Goose, or project-level `mcp.json` files and reference them by name. `generate-cli` reads a server's schemas and writes a standalone typed CLI script where every tool is a proper subcommand with flags and help text. - -🔐 **CIMD** (Client ID Metadata Documents) adds an alternative to Dynamic Client Registration for OAuth. Clients host a static JSON document at an HTTPS URL; that URL becomes the `client_id`. Server-side support includes SSRF-hardened fetching, cache-aware revalidation, and `private_key_jwt` validation. Enabled by default on `OAuthProxy`. - -📱 **MCP Apps** — Spec-level compliance for the MCP Apps extension: `ui://` resource scheme, typed UI metadata on tools and resources, extension negotiation, and `ctx.client_supports_extension()` for runtime detection. - -⏳ **Background Task Context** — `Context` now works transparently in Docket workers. `ctx.elicit()` routes through Redis-based coordination so background tasks can pause for user input without any code changes. - -🛡️ **ResponseLimitingMiddleware** caps tool response sizes with UTF-8-safe truncation for text and schema-aware error handling for structured outputs. - -🪿 **Goose Integration** — `fastmcp install goose` generates deeplink URLs for one-command server installation into Goose. - -## What's Changed -### New Features 🎉 -* Add MCP Apps Phase 1 — SDK compatibility (SEP-1865) by [@jlowin](https://github.com/jlowin) in [#3009](https://github.com/PrefectHQ/fastmcp/pull/3009) -* Add `fastmcp list` and `fastmcp call` CLI commands by [@jlowin](https://github.com/jlowin) in [#3054](https://github.com/PrefectHQ/fastmcp/pull/3054) -* Add `fastmcp generate-cli` command by [@jlowin](https://github.com/jlowin) in [#3065](https://github.com/PrefectHQ/fastmcp/pull/3065) -* Add CIMD (Client ID Metadata Document) support for OAuth by [@jlowin](https://github.com/jlowin) in [#2871](https://github.com/PrefectHQ/fastmcp/pull/2871) -### Enhancements 🔧 -* Make duplicate bot less aggressive by [@jlowin](https://github.com/jlowin) in [#2981](https://github.com/PrefectHQ/fastmcp/pull/2981) -* Remove uv lockfile monitoring from Dependabot by [@jlowin](https://github.com/jlowin) in [#2986](https://github.com/PrefectHQ/fastmcp/pull/2986) -* Run static checks with --upgrade, remove lockfile check by [@jlowin](https://github.com/jlowin) in [#2988](https://github.com/PrefectHQ/fastmcp/pull/2988) -* Adjust workflow triggers for Marvin by [@strawgate](https://github.com/strawgate) in [#3010](https://github.com/PrefectHQ/fastmcp/pull/3010) -* Move tests to a reusable action and enable nightly checks by [@strawgate](https://github.com/strawgate) in [#3017](https://github.com/PrefectHQ/fastmcp/pull/3017) -* feat: option to add upstream claims to the FastMCP proxy JWT by [@JonasKs](https://github.com/JonasKs) in [#2997](https://github.com/PrefectHQ/fastmcp/pull/2997) -* Fix ty 0.0.14 compatibility and upgrade dependencies by [@jlowin](https://github.com/jlowin) in [#3027](https://github.com/PrefectHQ/fastmcp/pull/3027) -* fix: automatically include offline_access as a scope in the Azure provider to enable automatic token refreshing by [@JonasKs](https://github.com/JonasKs) in [#3001](https://github.com/PrefectHQ/fastmcp/pull/3001) -* feat: expand --reload to watch frontend file types by [@jlowin](https://github.com/jlowin) in [#3028](https://github.com/PrefectHQ/fastmcp/pull/3028) -* Add `fastmcp install stdio` command by [@jlowin](https://github.com/jlowin) in [#3032](https://github.com/PrefectHQ/fastmcp/pull/3032) -* Update martian-issue-triage.yml for Workflow editing guidance by [@strawgate](https://github.com/strawgate) in [#3033](https://github.com/PrefectHQ/fastmcp/pull/3033) -* feat: Goose integration + dedicated install command by [@jlowin](https://github.com/jlowin) in [#3040](https://github.com/PrefectHQ/fastmcp/pull/3040) -* Fixing spelling issues in multiple files by [@didier-durand](https://github.com/didier-durand) in [#2996](https://github.com/PrefectHQ/fastmcp/pull/2996) -* Add `fastmcp discover` and name-based server resolution by [@jlowin](https://github.com/jlowin) in [#3055](https://github.com/PrefectHQ/fastmcp/pull/3055) -* feat(context): Add background task support for Context (SEP-1686) by [@gfortaine](https://github.com/gfortaine) in [#2905](https://github.com/PrefectHQ/fastmcp/pull/2905) -* Add server version to banner by [@richardkmichael](https://github.com/richardkmichael) in [#3076](https://github.com/PrefectHQ/fastmcp/pull/3076) -* Add @handle_tool_errors decorator for standardized error handling by [@dgenio](https://github.com/dgenio) in [#2885](https://github.com/PrefectHQ/fastmcp/pull/2885) -* Update Anthropic and OpenAI clients to use Omit instead of NotGiven by [@jlowin](https://github.com/jlowin) in [#3088](https://github.com/PrefectHQ/fastmcp/pull/3088) -* Add ResponseLimitingMiddleware for tool response size control by [@dgenio](https://github.com/dgenio) in [#3072](https://github.com/PrefectHQ/fastmcp/pull/3072) -* Infer MIME types from OpenAPI response definitions by [@jlowin](https://github.com/jlowin) in [#3101](https://github.com/PrefectHQ/fastmcp/pull/3101) -* Remove require_auth in favor of scope-based authorization by [@jlowin](https://github.com/jlowin) in [#3103](https://github.com/PrefectHQ/fastmcp/pull/3103) -### Fixes 🐞 -* Fix FastAPI mounting examples in docs by [@jlowin](https://github.com/jlowin) in [#2962](https://github.com/PrefectHQ/fastmcp/pull/2962) -* Remove outdated 'FastMCP 3.0 is coming!' CLI banner by [@jlowin](https://github.com/jlowin) in [#2974](https://github.com/PrefectHQ/fastmcp/pull/2974) -* Pin httpx `< 1.0` and simplify beta install docs by [@jlowin](https://github.com/jlowin) in [#2975](https://github.com/PrefectHQ/fastmcp/pull/2975) -* Add enabled field to ToolTransformConfig by [@jlowin](https://github.com/jlowin) in [#2991](https://github.com/PrefectHQ/fastmcp/pull/2991) -* fix phue2 import in smart_home example by [@zzstoatzz](https://github.com/zzstoatzz) in [#2999](https://github.com/PrefectHQ/fastmcp/pull/2999) -* fix: broaden combine_lifespans type to accept Mapping return types by [@aminsamir45](https://github.com/aminsamir45) in [#3005](https://github.com/PrefectHQ/fastmcp/pull/3005) -* fix: type narrowing for skills resource contents by [@strawgate](https://github.com/strawgate) in [#3023](https://github.com/PrefectHQ/fastmcp/pull/3023) -* fix: correctly send resource when exchanging code for the upstream by [@JonasKs](https://github.com/JonasKs) in [#3013](https://github.com/PrefectHQ/fastmcp/pull/3013) -* MCP Apps: structured CSP/permissions types, resource meta propagation fix, QR example by [@jlowin](https://github.com/jlowin) in [#3031](https://github.com/PrefectHQ/fastmcp/pull/3031) -* chore: upgrade python-multipart to 0.0.22 (CVE-2026-24486) by [@jlowin](https://github.com/jlowin) in [#3042](https://github.com/PrefectHQ/fastmcp/pull/3042) -* chore: upgrade protobuf to 6.33.5 (CVE-2026-0994) by [@jlowin](https://github.com/jlowin) in [#3043](https://github.com/PrefectHQ/fastmcp/pull/3043) -* fix: use MCP spec error code -32002 for resource not found by [@jlowin](https://github.com/jlowin) in [#3041](https://github.com/PrefectHQ/fastmcp/pull/3041) -* Fix tool_choice reset for structured output sampling by [@strawgate](https://github.com/strawgate) in [#3014](https://github.com/PrefectHQ/fastmcp/pull/3014) -* Fix workflow notification URL formatting in upgrade checks by [@strawgate](https://github.com/strawgate) in [#3047](https://github.com/PrefectHQ/fastmcp/pull/3047) -* Fix Field() handling in prompts by [@strawgate](https://github.com/strawgate) in [#3050](https://github.com/PrefectHQ/fastmcp/pull/3050) -* fix: use SkipJsonSchema to exclude callable fields from JSON schema generation by [@strawgate](https://github.com/strawgate) in [#3048](https://github.com/PrefectHQ/fastmcp/pull/3048) -* fix: Preserve metadata in FastMCPProvider component wrappers by [@NeelayS](https://github.com/NeelayS) in [#3057](https://github.com/PrefectHQ/fastmcp/pull/3057) -* Mock network calls in CLI tests and use MemoryStore for OAuth tests by [@strawgate](https://github.com/strawgate) in [#3051](https://github.com/PrefectHQ/fastmcp/pull/3051) -* Remove OpenAPI timeout parameter, make client optional, surface timeout errors by [@jlowin](https://github.com/jlowin) in [#3067](https://github.com/PrefectHQ/fastmcp/pull/3067) -* fix: enforce redirect URI validation when allowed_client_redirect_uris is supplied by [@nathanwelsh8](https://github.com/nathanwelsh8) in [#3066](https://github.com/PrefectHQ/fastmcp/pull/3066) -* Fix --reload port conflict when using explicit port by [@jlowin](https://github.com/jlowin) in [#3070](https://github.com/PrefectHQ/fastmcp/pull/3070) -* Fix compress_schema to preserve additionalProperties: false for MCP compatibility by [@jlowin](https://github.com/jlowin) in [#3102](https://github.com/PrefectHQ/fastmcp/pull/3102) -* Fix CIMD redirect allowlist bypass and cache revalidation by [@jlowin](https://github.com/jlowin) in [#3098](https://github.com/PrefectHQ/fastmcp/pull/3098) -* Exclude content-type from get_http_headers() to prevent HTTP 415 errors by [@jlowin](https://github.com/jlowin) in [#3104](https://github.com/PrefectHQ/fastmcp/pull/3104) -### Docs 📚 -* Prepare docs for v3.0 beta release by [@jlowin](https://github.com/jlowin) in [#2954](https://github.com/PrefectHQ/fastmcp/pull/2954) -* Restructure docs: move transforms to dedicated section by [@jlowin](https://github.com/jlowin) in [#2956](https://github.com/PrefectHQ/fastmcp/pull/2956) -* Remove unnecessary pip warning by [@jlowin](https://github.com/jlowin) in [#2958](https://github.com/PrefectHQ/fastmcp/pull/2958) -* Update example MCP version in installation docs by [@jlowin](https://github.com/jlowin) in [#2959](https://github.com/PrefectHQ/fastmcp/pull/2959) -* Update brand images by [@jlowin](https://github.com/jlowin) in [#2960](https://github.com/PrefectHQ/fastmcp/pull/2960) -* Restructure README and welcome page with motivated narrative by [@jlowin](https://github.com/jlowin) in [#2963](https://github.com/PrefectHQ/fastmcp/pull/2963) -* Restructure README and docs with motivated narrative by [@jlowin](https://github.com/jlowin) in [#2964](https://github.com/PrefectHQ/fastmcp/pull/2964) -* Favicon update and Prefect Horizon docs by [@jlowin](https://github.com/jlowin) in [#2978](https://github.com/PrefectHQ/fastmcp/pull/2978) -* Add dependency injection documentation and DI-style dependencies by [@jlowin](https://github.com/jlowin) in [#2980](https://github.com/PrefectHQ/fastmcp/pull/2980) -* docs: document expanded reload behavior and restructure beta sections by [@jlowin](https://github.com/jlowin) in [#3039](https://github.com/PrefectHQ/fastmcp/pull/3039) -* Add output_schema caveat to response limiting docs by [@jlowin](https://github.com/jlowin) in [#3099](https://github.com/PrefectHQ/fastmcp/pull/3099) -* Document token passthrough security in OAuth Proxy docs by [@jlowin](https://github.com/jlowin) in [#3100](https://github.com/PrefectHQ/fastmcp/pull/3100) -### Dependencies 📦 -* Bump ty from 0.0.12 to 0.0.13 by @dependabot in [#2984](https://github.com/PrefectHQ/fastmcp/pull/2984) -* Bump prek from 0.2.30 to 0.3.0 by @dependabot in [#2982](https://github.com/PrefectHQ/fastmcp/pull/2982) -### Other Changes 🦾 -* Normalize resource URLs before comparison to support RFC 8707 query parameters by [@abhijeethp](https://github.com/abhijeethp) in [#2967](https://github.com/PrefectHQ/fastmcp/pull/2967) -* Bump pydocket to 0.17.2 (memory leak fix) by [@chrisguidry](https://github.com/chrisguidry) in [#2998](https://github.com/PrefectHQ/fastmcp/pull/2998) -* Add AzureJWTVerifier for Managed Identity token verification by [@jlowin](https://github.com/jlowin) in [#3058](https://github.com/PrefectHQ/fastmcp/pull/3058) -* Add release notes for v2.14.4 and v2.14.5 by [@jlowin](https://github.com/jlowin) in [#3064](https://github.com/PrefectHQ/fastmcp/pull/3064) -* Add missing beta2 features to v3 release tracking by [@jlowin](https://github.com/jlowin) in [#3105](https://github.com/PrefectHQ/fastmcp/pull/3105) - -## New Contributors -* [@abhijeethp](https://github.com/abhijeethp) made their first contribution in [#2967](https://github.com/PrefectHQ/fastmcp/pull/2967) -* [@aminsamir45](https://github.com/aminsamir45) made their first contribution in [#3005](https://github.com/PrefectHQ/fastmcp/pull/3005) -* [@JonasKs](https://github.com/JonasKs) made their first contribution in [#2997](https://github.com/PrefectHQ/fastmcp/pull/2997) -* [@NeelayS](https://github.com/NeelayS) made their first contribution in [#3057](https://github.com/PrefectHQ/fastmcp/pull/3057) -* [@gfortaine](https://github.com/gfortaine) made their first contribution in [#2905](https://github.com/PrefectHQ/fastmcp/pull/2905) -* [@nathanwelsh8](https://github.com/nathanwelsh8) made their first contribution in [#3066](https://github.com/PrefectHQ/fastmcp/pull/3066) -* [@dgenio](https://github.com/dgenio) made their first contribution in [#2885](https://github.com/PrefectHQ/fastmcp/pull/2885) - -**Full Changelog**: https://github.com/PrefectHQ/fastmcp/compare/v3.0.0b1...v3.0.0b2 - -</Update> - -<Update label="v3.0.0b1" description="2026-01-20"> - -**[v3.0.0b1: This Beta Work](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0b1)** - -FastMCP 3.0 rebuilds the framework around three primitives: components, providers, and transforms. Providers source components dynamically—from decorators, filesystems, OpenAPI specs, remote servers, or anywhere else. Transforms modify components as they flow to clients—renaming, namespacing, filtering, securing. The features that required specialized subsystems in v2 now compose naturally from these building blocks. - -🔌 **Provider Architecture** unifies how components are sourced. `FileSystemProvider` discovers decorated functions from directories with optional hot-reload. `SkillsProvider` exposes agent skill files as MCP resources. `OpenAPIProvider` and `ProxyProvider` get cleaner integrations. Providers are composable—share one across servers, or attach many to one server. - -🔄 **Transforms** add middleware for components. Namespace mounted servers, rename verbose tools, filter by version, control visibility—all without touching source code. `ResourcesAsTools` and `PromptsAsTools` expose non-tool components to tool-only clients. - -📋 **Component Versioning** lets you register `@tool(version="2.0")` alongside older versions. Clients see the highest version by default but can request specific versions. `VersionFilter` serves different API versions from one codebase. - -💾 **Session-Scoped State** persists across requests. `await ctx.set_state()` and `await ctx.get_state()` now survive the full session. Per-session visibility via `ctx.enable_components()` lets servers adapt dynamically to each client. - -⚡ **DX Improvements** include `--reload` for auto-restart during development, automatic threadpool dispatch for sync functions, tool timeouts, pagination for large component lists, and OpenTelemetry tracing. - -🔐 **Component Authorization** via `@tool(auth=require_scopes("admin"))` and `AuthMiddleware` for server-wide policies. - -Breaking changes are minimal: for most servers, updating the import statement is all you need. See the [migration guide](https://github.com/PrefectHQ/fastmcp/blob/main/docs/getting-started/upgrading/from-fastmcp-2.mdx) for details. - -## What's Changed -### New Features 🎉 -* Refactor resource behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2611](https://github.com/PrefectHQ/fastmcp/pull/2611) -* Refactor prompt behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2610](https://github.com/PrefectHQ/fastmcp/pull/2610) -* feat: Provider abstraction for dynamic MCP components by [@jlowin](https://github.com/jlowin) in [#2622](https://github.com/PrefectHQ/fastmcp/pull/2622) -* Unify component storage in LocalProvider by [@jlowin](https://github.com/jlowin) in [#2680](https://github.com/PrefectHQ/fastmcp/pull/2680) -* Introduce ResourceResult as canonical resource return type by [@jlowin](https://github.com/jlowin) in [#2734](https://github.com/PrefectHQ/fastmcp/pull/2734) -* Introduce Message and PromptResult as canonical prompt types by [@jlowin](https://github.com/jlowin) in [#2738](https://github.com/PrefectHQ/fastmcp/pull/2738) -* Add --reload flag for auto-restart on file changes by [@jlowin](https://github.com/jlowin) in [#2816](https://github.com/PrefectHQ/fastmcp/pull/2816) -* Add FileSystemProvider for filesystem-based component discovery by [@jlowin](https://github.com/jlowin) in [#2823](https://github.com/PrefectHQ/fastmcp/pull/2823) -* Add standalone decorators and eliminate fastmcp.fs module by [@jlowin](https://github.com/jlowin) in [#2832](https://github.com/PrefectHQ/fastmcp/pull/2832) -* Add authorization checks to components and servers by [@jlowin](https://github.com/jlowin) in [#2855](https://github.com/PrefectHQ/fastmcp/pull/2855) -* Decorators return functions instead of component objects by [@jlowin](https://github.com/jlowin) in [#2856](https://github.com/PrefectHQ/fastmcp/pull/2856) -* Add transform system for modifying components in provider chains by [@jlowin](https://github.com/jlowin) in [#2836](https://github.com/PrefectHQ/fastmcp/pull/2836) -* Add OpenTelemetry tracing support by [@chrisguidry](https://github.com/chrisguidry) in [#2869](https://github.com/PrefectHQ/fastmcp/pull/2869) -* Add component versioning and VersionFilter transform by [@jlowin](https://github.com/jlowin) in [#2894](https://github.com/PrefectHQ/fastmcp/pull/2894) -* Add version discovery and calling a certain version for components by [@jlowin](https://github.com/jlowin) in [#2897](https://github.com/PrefectHQ/fastmcp/pull/2897) -* Refactor visibility to mark-based enabled system by [@jlowin](https://github.com/jlowin) in [#2912](https://github.com/PrefectHQ/fastmcp/pull/2912) -* Add session-specific visibility control via Context by [@jlowin](https://github.com/jlowin) in [#2917](https://github.com/PrefectHQ/fastmcp/pull/2917) -* Add Skills Provider for exposing agent skills as MCP resources by [@jlowin](https://github.com/jlowin) in [#2944](https://github.com/PrefectHQ/fastmcp/pull/2944) -### Enhancements 🔧 -* Convert mounted servers to MountedProvider by [@jlowin](https://github.com/jlowin) in [#2635](https://github.com/PrefectHQ/fastmcp/pull/2635) -* Simplify .key as computed property by [@jlowin](https://github.com/jlowin) in [#2648](https://github.com/PrefectHQ/fastmcp/pull/2648) -* Refactor MountedProvider into FastMCPProvider + TransformingProvider by [@jlowin](https://github.com/jlowin) in [#2653](https://github.com/PrefectHQ/fastmcp/pull/2653) -* Enable background task support for custom component subclasses by [@jlowin](https://github.com/jlowin) in [#2657](https://github.com/PrefectHQ/fastmcp/pull/2657) -* Use CreateTaskResult for background task creation by [@jlowin](https://github.com/jlowin) in [#2660](https://github.com/PrefectHQ/fastmcp/pull/2660) -* Refactor provider execution: components own their execution by [@jlowin](https://github.com/jlowin) in [#2663](https://github.com/PrefectHQ/fastmcp/pull/2663) -* Add supports_tasks() method to replace string mode checks by [@jlowin](https://github.com/jlowin) in [#2664](https://github.com/PrefectHQ/fastmcp/pull/2664) -* Replace type: ignore[attr-defined] with isinstance assertions in tests by [@jlowin](https://github.com/jlowin) in [#2665](https://github.com/PrefectHQ/fastmcp/pull/2665) -* Add poll_interval to TaskConfig by [@jlowin](https://github.com/jlowin) in [#2666](https://github.com/PrefectHQ/fastmcp/pull/2666) -* Refactor task module: rename protocol.py to requests.py and reduce redundancy by [@jlowin](https://github.com/jlowin) in [#2667](https://github.com/PrefectHQ/fastmcp/pull/2667) -* Refactor FastMCPProxy into ProxyProvider by [@jlowin](https://github.com/jlowin) in [#2669](https://github.com/PrefectHQ/fastmcp/pull/2669) -* Move OpenAPI to providers/openapi submodule by [@jlowin](https://github.com/jlowin) in [#2672](https://github.com/PrefectHQ/fastmcp/pull/2672) -* Use ergonomic provider initialization pattern by [@jlowin](https://github.com/jlowin) in [#2675](https://github.com/PrefectHQ/fastmcp/pull/2675) -* Fix ty 0.0.5 type errors by [@jlowin](https://github.com/jlowin) in [#2676](https://github.com/PrefectHQ/fastmcp/pull/2676) -* Remove execution methods from Provider base class by [@jlowin](https://github.com/jlowin) in [#2681](https://github.com/PrefectHQ/fastmcp/pull/2681) -* Add type-prefixed keys for globally unique component identification by [@jlowin](https://github.com/jlowin) in [#2704](https://github.com/PrefectHQ/fastmcp/pull/2704) -* Skip parallel MCP config test on Windows by [@jlowin](https://github.com/jlowin) in [#2711](https://github.com/PrefectHQ/fastmcp/pull/2711) -* Consolidate notification system with unified API by [@jlowin](https://github.com/jlowin) in [#2710](https://github.com/PrefectHQ/fastmcp/pull/2710) -* Skip test_multi_client on Windows by [@jlowin](https://github.com/jlowin) in [#2714](https://github.com/PrefectHQ/fastmcp/pull/2714) -* Parallelize provider operations by [@jlowin](https://github.com/jlowin) in [#2716](https://github.com/PrefectHQ/fastmcp/pull/2716) -* Consolidate get_* and _list_* methods into single API by [@jlowin](https://github.com/jlowin) in [#2719](https://github.com/PrefectHQ/fastmcp/pull/2719) -* Consolidate execution method chains into single public API by [@jlowin](https://github.com/jlowin) in [#2728](https://github.com/PrefectHQ/fastmcp/pull/2728) -* Add documentation check to required PR workflow by [@jlowin](https://github.com/jlowin) in [#2730](https://github.com/PrefectHQ/fastmcp/pull/2730) -* Parallelize list_* calls in Provider.get_tasks() by [@jlowin](https://github.com/jlowin) in [#2731](https://github.com/PrefectHQ/fastmcp/pull/2731) -* Consistent decorator-based MCP handler registration by [@jlowin](https://github.com/jlowin) in [#2732](https://github.com/PrefectHQ/fastmcp/pull/2732) -* Make ToolResult a BaseModel for serialization support by [@jlowin](https://github.com/jlowin) in [#2736](https://github.com/PrefectHQ/fastmcp/pull/2736) -* Align prompt handler with resource pattern by [@jlowin](https://github.com/jlowin) in [#2740](https://github.com/PrefectHQ/fastmcp/pull/2740) -* Update classes to inherit from FastMCPBaseModel instead of BaseModel by [@jlowin](https://github.com/jlowin) in [#2739](https://github.com/PrefectHQ/fastmcp/pull/2739) -* Convert provider tests to use direct server calls by [@jlowin](https://github.com/jlowin) in [#2748](https://github.com/PrefectHQ/fastmcp/pull/2748) -* Add explicit task_meta parameter to FastMCP.call_tool() by [@jlowin](https://github.com/jlowin) in [#2749](https://github.com/PrefectHQ/fastmcp/pull/2749) -* Add task_meta parameter to read_resource() for explicit task control by [@jlowin](https://github.com/jlowin) in [#2750](https://github.com/PrefectHQ/fastmcp/pull/2750) -* Add task_meta to prompts and centralize fn_key enrichment by [@jlowin](https://github.com/jlowin) in [#2751](https://github.com/PrefectHQ/fastmcp/pull/2751) -* Remove unused include_tags/exclude_tags settings by [@jlowin](https://github.com/jlowin) in [#2756](https://github.com/PrefectHQ/fastmcp/pull/2756) -* Parallelize provider access when executing components by [@jlowin](https://github.com/jlowin) in [#2744](https://github.com/PrefectHQ/fastmcp/pull/2744) -* Add tests for OAuth generator cleanup and use aclosing by [@jlowin](https://github.com/jlowin) in [#2759](https://github.com/PrefectHQ/fastmcp/pull/2759) -* Deprecate tool_serializer parameter by [@jlowin](https://github.com/jlowin) in [#2753](https://github.com/PrefectHQ/fastmcp/pull/2753) -* Feature/supabase custom auth route by [@EloiZalczer](https://github.com/EloiZalczer) in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) -* Add regression tests for caching with mounted server prefixes by [@jlowin](https://github.com/jlowin) in [#2762](https://github.com/PrefectHQ/fastmcp/pull/2762) -* Update CLI banner with FastMCP 3.0 notice by [@jlowin](https://github.com/jlowin) in [#2766](https://github.com/PrefectHQ/fastmcp/pull/2766) -* Make FASTMCP_SHOW_SERVER_BANNER apply to all server startup methods by [@jlowin](https://github.com/jlowin) in [#2771](https://github.com/PrefectHQ/fastmcp/pull/2771) -* Add MCP tool annotations to smart_home example by [@triepod-ai](https://github.com/triepod-ai) in [#2777](https://github.com/PrefectHQ/fastmcp/pull/2777) -* Cherry-pick debug logging for OAuth token expiry to main by [@jlowin](https://github.com/jlowin) in [#2797](https://github.com/PrefectHQ/fastmcp/pull/2797) -* Turn off negative CLI flags by default by [@jlowin](https://github.com/jlowin) in [#2801](https://github.com/PrefectHQ/fastmcp/pull/2801) -* Configure ty to fail on warnings by [@jlowin](https://github.com/jlowin) in [#2804](https://github.com/PrefectHQ/fastmcp/pull/2804) -* Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2814](https://github.com/PrefectHQ/fastmcp/pull/2814) -* Add v3.0 feature tracking document by [@jlowin](https://github.com/jlowin) in [#2822](https://github.com/PrefectHQ/fastmcp/pull/2822) -* Remove deprecated WSTransport by [@jlowin](https://github.com/jlowin) in [#2826](https://github.com/PrefectHQ/fastmcp/pull/2826) -* Add composable lifespans by [@jlowin](https://github.com/jlowin) in [#2828](https://github.com/PrefectHQ/fastmcp/pull/2828) -* Replace FastMCP.as_proxy() with create_proxy() function by [@jlowin](https://github.com/jlowin) in [#2829](https://github.com/PrefectHQ/fastmcp/pull/2829) -* Add docs-broken-links command and fix docstring markdown parsing by [@jlowin](https://github.com/jlowin) in [#2830](https://github.com/PrefectHQ/fastmcp/pull/2830) -* Add PingMiddleware for keepalive connections by [@jlowin](https://github.com/jlowin) in [#2838](https://github.com/PrefectHQ/fastmcp/pull/2838) -* Add CLI update notifications by [@jlowin](https://github.com/jlowin) in [#2840](https://github.com/PrefectHQ/fastmcp/pull/2840) -* Add agent skills for testing and code review by [@jlowin](https://github.com/jlowin) in [#2846](https://github.com/PrefectHQ/fastmcp/pull/2846) -* Add loq pre-commit hook for file size enforcement by [@jlowin](https://github.com/jlowin) in [#2847](https://github.com/PrefectHQ/fastmcp/pull/2847) -* Add transport property to Context by [@jlowin](https://github.com/jlowin) in [#2850](https://github.com/PrefectHQ/fastmcp/pull/2850) -* Add loq file size limits and clean up type ignores by [@jlowin](https://github.com/jlowin) in [#2859](https://github.com/PrefectHQ/fastmcp/pull/2859) -* Run sync tools/resources/prompts in threadpool automatically by [@jlowin](https://github.com/jlowin) in [#2865](https://github.com/PrefectHQ/fastmcp/pull/2865) -* Add timeout parameter for tool foreground execution by [@jlowin](https://github.com/jlowin) in [#2872](https://github.com/PrefectHQ/fastmcp/pull/2872) -* Adopt OpenTelemetry MCP semantic conventions by [@chrisguidry](https://github.com/chrisguidry) in [#2886](https://github.com/PrefectHQ/fastmcp/pull/2886) -* Add client_secret_post authentication to IntrospectionTokenVerifier by [@shulkx](https://github.com/shulkx) in [#2884](https://github.com/PrefectHQ/fastmcp/pull/2884) -* Add enable_rich_logging setting to disable rich formatting by [@strawgate](https://github.com/strawgate) in [#2893](https://github.com/PrefectHQ/fastmcp/pull/2893) -* Rename _fastmcp metadata namespace to fastmcp and make non-optional by [@jlowin](https://github.com/jlowin) in [#2895](https://github.com/PrefectHQ/fastmcp/pull/2895) -* Refactor FastMCP to inherit from Provider by [@jlowin](https://github.com/jlowin) in [#2901](https://github.com/PrefectHQ/fastmcp/pull/2901) -* Swap public/private method naming in Provider by [@jlowin](https://github.com/jlowin) in [#2902](https://github.com/PrefectHQ/fastmcp/pull/2902) -* Add MCP-compliant pagination support by [@jlowin](https://github.com/jlowin) in [#2903](https://github.com/PrefectHQ/fastmcp/pull/2903) -* Support VersionSpec in enable/disable for range-based filtering by [@jlowin](https://github.com/jlowin) in [#2914](https://github.com/PrefectHQ/fastmcp/pull/2914) -* Remove sync notification infrastructure by [@jlowin](https://github.com/jlowin) in [#2915](https://github.com/PrefectHQ/fastmcp/pull/2915) -* Immutable transform wrapping for providers by [@jlowin](https://github.com/jlowin) in [#2913](https://github.com/PrefectHQ/fastmcp/pull/2913) -* Unify discovery API: deduplicate at protocol layer only by [@jlowin](https://github.com/jlowin) in [#2919](https://github.com/PrefectHQ/fastmcp/pull/2919) -* Split transports.py into modular structure by [@jlowin](https://github.com/jlowin) in [#2921](https://github.com/PrefectHQ/fastmcp/pull/2921) -* Move session visibility logic to enabled.py by [@jlowin](https://github.com/jlowin) in [#2924](https://github.com/PrefectHQ/fastmcp/pull/2924) -* Refactor Client class into mixins and add timeout utilities by [@jlowin](https://github.com/jlowin) in [#2933](https://github.com/PrefectHQ/fastmcp/pull/2933) -* Refactor OAuthProxy into focused modules by [@jlowin](https://github.com/jlowin) in [#2935](https://github.com/PrefectHQ/fastmcp/pull/2935) -* Refactor LocalProvider into mixin modules by [@jlowin](https://github.com/jlowin) in [#2936](https://github.com/PrefectHQ/fastmcp/pull/2936) -* Refactor server.py into mixins by [@jlowin](https://github.com/jlowin) in [#2939](https://github.com/PrefectHQ/fastmcp/pull/2939) -* Consolidate test fixtures and refactor large test files by [@jlowin](https://github.com/jlowin) in [#2941](https://github.com/PrefectHQ/fastmcp/pull/2941) -* Refactor transform list methods to pure function pattern by [@jlowin](https://github.com/jlowin) in [#2942](https://github.com/PrefectHQ/fastmcp/pull/2942) -* Add ResourcesAsTools transform by [@jlowin](https://github.com/jlowin) in [#2943](https://github.com/PrefectHQ/fastmcp/pull/2943) -* Add PromptsAsTools transform by [@jlowin](https://github.com/jlowin) in [#2946](https://github.com/PrefectHQ/fastmcp/pull/2946) -* Add client utilities for downloading skills by [@jlowin](https://github.com/jlowin) in [#2948](https://github.com/PrefectHQ/fastmcp/pull/2948) -* Rename Enabled transform to Visibility by [@jlowin](https://github.com/jlowin) in [#2950](https://github.com/PrefectHQ/fastmcp/pull/2950) -### Fixes 🐞 -* Let FastMCPError propagate from dependencies by [@chrisguidry](https://github.com/chrisguidry) in [#2646](https://github.com/PrefectHQ/fastmcp/pull/2646) -* Fix task execution for tools with custom names by [@chrisguidry](https://github.com/chrisguidry) in [#2645](https://github.com/PrefectHQ/fastmcp/pull/2645) -* fix: check the cause of the tool error by [@rjolaverria](https://github.com/rjolaverria) in [#2674](https://github.com/PrefectHQ/fastmcp/pull/2674) -* Bump pydocket to 0.16.3 for task cancellation support by [@chrisguidry](https://github.com/chrisguidry) in [#2683](https://github.com/PrefectHQ/fastmcp/pull/2683) -* Fix uvicorn 0.39+ test timeouts and FastMCPError propagation by [@jlowin](https://github.com/jlowin) in [#2699](https://github.com/PrefectHQ/fastmcp/pull/2699) -* Fix Prefect website URL in docs footer by [@mgoldsborough](https://github.com/mgoldsborough) in [#2701](https://github.com/PrefectHQ/fastmcp/pull/2701) -* Fix: resolve root-level $ref in outputSchema for MCP spec compliance by [@majiayu000](https://github.com/majiayu000) in [#2720](https://github.com/PrefectHQ/fastmcp/pull/2720) -* Fix Provider.get_tasks() to include custom component subclasses by [@jlowin](https://github.com/jlowin) in [#2729](https://github.com/PrefectHQ/fastmcp/pull/2729) -* Fix Proxy provider to return all resource contents by [@jlowin](https://github.com/jlowin) in [#2742](https://github.com/PrefectHQ/fastmcp/pull/2742) -* Fix prompt return type documentation by [@jlowin](https://github.com/jlowin) in [#2741](https://github.com/PrefectHQ/fastmcp/pull/2741) -* fix: Client OAuth async_auth_flow() method causing MCP-SDK self.context.lock error. by [@lgndluke](https://github.com/lgndluke) in [#2644](https://github.com/PrefectHQ/fastmcp/pull/2644) -* Fix rate limit detection during teardown phase by [@jlowin](https://github.com/jlowin) in [#2757](https://github.com/PrefectHQ/fastmcp/pull/2757) -* fix: set pytest-asyncio default fixture loop scope to function by [@jlowin](https://github.com/jlowin) in [#2758](https://github.com/PrefectHQ/fastmcp/pull/2758) -* Fix OAuth Proxy resource parameter validation by [@jlowin](https://github.com/jlowin) in [#2764](https://github.com/PrefectHQ/fastmcp/pull/2764) -* [BugFix] Fix `openapi_version` Check So 3.1 Is Included by [@deeleeramone](https://github.com/deeleeramone) in [#2768](https://github.com/PrefectHQ/fastmcp/pull/2768) -* Fix titled enum elicitation schema to comply with MCP spec by [@jlowin](https://github.com/jlowin) in [#2773](https://github.com/PrefectHQ/fastmcp/pull/2773) -* Fix base_url fallback when url is not set by [@bhbs](https://github.com/bhbs) in [#2776](https://github.com/PrefectHQ/fastmcp/pull/2776) -* Lazy import DiskStore to avoid sqlite3 dependency on import by [@jlowin](https://github.com/jlowin) in [#2784](https://github.com/PrefectHQ/fastmcp/pull/2784) -* Fix OAuth token storage TTL calculation by [@jlowin](https://github.com/jlowin) in [#2796](https://github.com/PrefectHQ/fastmcp/pull/2796) -* Use consistent refresh_ttl for JTI mapping store by [@jlowin](https://github.com/jlowin) in [#2799](https://github.com/PrefectHQ/fastmcp/pull/2799) -* Return 401 for invalid_grant token errors per MCP spec by [@jlowin](https://github.com/jlowin) in [#2800](https://github.com/PrefectHQ/fastmcp/pull/2800) -* Fix client hanging on HTTP 4xx/5xx errors by [@jlowin](https://github.com/jlowin) in [#2803](https://github.com/PrefectHQ/fastmcp/pull/2803) -* Fix unawaited coroutine warning and treat as test error by [@jlowin](https://github.com/jlowin) in [#2806](https://github.com/PrefectHQ/fastmcp/pull/2806) -* Fix keep_alive passthrough in StdioMCPServer.to_transport() by [@jlowin](https://github.com/jlowin) in [#2791](https://github.com/PrefectHQ/fastmcp/pull/2791) -* Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2808](https://github.com/PrefectHQ/fastmcp/pull/2808) -* Prefix Redis keys with docket name for ACL isolation by [@chrisguidry](https://github.com/chrisguidry) in [#2811](https://github.com/PrefectHQ/fastmcp/pull/2811) -* fix smart_home example: HueAttributes schema and deprecated prefix by [@zzstoatzz](https://github.com/zzstoatzz) in [#2818](https://github.com/PrefectHQ/fastmcp/pull/2818) -* Fix redirect URI validation docs to match implementation by [@jlowin](https://github.com/jlowin) in [#2824](https://github.com/PrefectHQ/fastmcp/pull/2824) -* Fix timeout not propagating to proxy clients in multi-server MCPConfig by [@jlowin](https://github.com/jlowin) in [#2809](https://github.com/PrefectHQ/fastmcp/pull/2809) -* Fix ContextVar propagation for ASGI-mounted servers with tasks by [@chrisguidry](https://github.com/chrisguidry) in [#2844](https://github.com/PrefectHQ/fastmcp/pull/2844) -* Fix HTTP transport timeout defaulting to 5 seconds by [@jlowin](https://github.com/jlowin) in [#2849](https://github.com/PrefectHQ/fastmcp/pull/2849) -* Fix decorator error messages to link to correct doc pages by [@jlowin](https://github.com/jlowin) in [#2858](https://github.com/PrefectHQ/fastmcp/pull/2858) -* Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2875](https://github.com/PrefectHQ/fastmcp/pull/2875) -* Bump the uv group across 1 directory with 2 updates by [@dependabot](https://github.com/dependabot)\[bot\] in [#2890](https://github.com/PrefectHQ/fastmcp/pull/2890) -### Breaking Changes 🛫 -* Add VisibilityFilter for hierarchical enable/disable by [@jlowin](https://github.com/jlowin) in [#2708](https://github.com/PrefectHQ/fastmcp/pull/2708) -* Remove automatic environment variable loading from auth providers by [@jlowin](https://github.com/jlowin) in [#2752](https://github.com/PrefectHQ/fastmcp/pull/2752) -* Make pydocket optional and unify DI systems by [@jlowin](https://github.com/jlowin) in [#2835](https://github.com/PrefectHQ/fastmcp/pull/2835) -* Add session-scoped state persistence by [@jlowin](https://github.com/jlowin) in [#2873](https://github.com/PrefectHQ/fastmcp/pull/2873) -### Docs 📚 -* Undocumented `McpError` exceptions by [@ivanbelenky](https://github.com/ivanbelenky) in [#2656](https://github.com/PrefectHQ/fastmcp/pull/2656) -* docs(server): add http to transport options in run() method docstring by [@Ashif4354](https://github.com/Ashif4354) in [#2707](https://github.com/PrefectHQ/fastmcp/pull/2707) -* Add v3 breaking changes notice to README by [@jlowin](https://github.com/jlowin) in [#2712](https://github.com/PrefectHQ/fastmcp/pull/2712) -* Add changelog entries for v2.13.1 through v2.14.1 by [@jlowin](https://github.com/jlowin) in [#2725](https://github.com/PrefectHQ/fastmcp/pull/2725) -* Reorganize docs around provider architecture by [@jlowin](https://github.com/jlowin) in [#2723](https://github.com/PrefectHQ/fastmcp/pull/2723) -* Fix documentation to use 'meta' instead of '_meta' for MCP spec field by [@jlowin](https://github.com/jlowin) in [#2735](https://github.com/PrefectHQ/fastmcp/pull/2735) -* Enhance documentation on tool transformation by [@shea-parkes](https://github.com/shea-parkes) in [#2781](https://github.com/PrefectHQ/fastmcp/pull/2781) -* Add FastMCP 4.0 preview to documentation by [@jlowin](https://github.com/jlowin) in [#2831](https://github.com/PrefectHQ/fastmcp/pull/2831) -* Add release notes for v2.14.2 and v2.14.3 by [@jlowin](https://github.com/jlowin) in [#2852](https://github.com/PrefectHQ/fastmcp/pull/2852) -* Add missing 3.0.0 version badges and document tasks extra by [@jlowin](https://github.com/jlowin) in [#2866](https://github.com/PrefectHQ/fastmcp/pull/2866) -* Fix custom provider docs to show correct interface by [@jlowin](https://github.com/jlowin) in [#2920](https://github.com/PrefectHQ/fastmcp/pull/2920) -* Update v3 features that were missed in PRs by [@jlowin](https://github.com/jlowin) in [#2947](https://github.com/PrefectHQ/fastmcp/pull/2947) -* Restructure documentation for FastMCP 3.0 by [@jlowin](https://github.com/jlowin) in [#2951](https://github.com/PrefectHQ/fastmcp/pull/2951) -* Fix broken documentation links by [@jlowin](https://github.com/jlowin) in [#2952](https://github.com/PrefectHQ/fastmcp/pull/2952) -* Clarify installation for FastMCP 3.0 beta by [@jlowin](https://github.com/jlowin) in [#2953](https://github.com/PrefectHQ/fastmcp/pull/2953) -### Dependencies 📦 -* Bump peter-evans/create-pull-request from 7 to 8 by [@dependabot](https://github.com/dependabot)\[bot\] in [#2623](https://github.com/PrefectHQ/fastmcp/pull/2623) -* Bump ty to 0.0.7+ by [@jlowin](https://github.com/jlowin) in [#2737](https://github.com/PrefectHQ/fastmcp/pull/2737) -* Bump the uv group across 1 directory with 4 updates by [@dependabot](https://github.com/dependabot)\[bot\] in [#2891](https://github.com/PrefectHQ/fastmcp/pull/2891) - -## New Contributors -* [@ivanbelenky](https://github.com/ivanbelenky) made their first contribution in [#2656](https://github.com/PrefectHQ/fastmcp/pull/2656) -* [@rjolaverria](https://github.com/rjolaverria) made their first contribution in [#2674](https://github.com/PrefectHQ/fastmcp/pull/2674) -* [@mgoldsborough](https://github.com/mgoldsborough) made their first contribution in [#2701](https://github.com/PrefectHQ/fastmcp/pull/2701) -* [@Ashif4354](https://github.com/Ashif4354) made their first contribution in [#2707](https://github.com/PrefectHQ/fastmcp/pull/2707) -* [@majiayu000](https://github.com/majiayu000) made their first contribution in [#2720](https://github.com/PrefectHQ/fastmcp/pull/2720) -* [@lgndluke](https://github.com/lgndluke) made their first contribution in [#2644](https://github.com/PrefectHQ/fastmcp/pull/2644) -* [@EloiZalczer](https://github.com/EloiZalczer) made their first contribution in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) -* [@deeleeramone](https://github.com/deeleeramone) made their first contribution in [#2768](https://github.com/PrefectHQ/fastmcp/pull/2768) -* [@shea-parkes](https://github.com/shea-parkes) made their first contribution in [#2781](https://github.com/PrefectHQ/fastmcp/pull/2781) -* [@triepod-ai](https://github.com/triepod-ai) made their first contribution in [#2777](https://github.com/PrefectHQ/fastmcp/pull/2777) -* [@bhbs](https://github.com/bhbs) made their first contribution in [#2776](https://github.com/PrefectHQ/fastmcp/pull/2776) -* [@shulkx](https://github.com/shulkx) made their first contribution in [#2884](https://github.com/PrefectHQ/fastmcp/pull/2884) - -**Full Changelog**: [v2.14.1...v3.0.0b1](https://github.com/PrefectHQ/fastmcp/compare/v2.14.1...v3.0.0b1) - -</Update> - -<Update label="v2.14.7" description="2026-04-13"> - -**[v2.14.7: Fake It Till You Break It](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.7)** - -A 2.x backport of the fakeredis pin: fakeredis 2.35.0 renamed a connection class that pydocket's `memory://` backend depended on, crashing `fastmcp[tasks]` installs at startup. This caps `fakeredis<2.35.0` on the 2.x line. - -### Fixes 🐞 -* fix(deps): cap fakeredis to `<2.35.0` to prevent startup crash on 2.x by [@vincent067](https://github.com/vincent067) in [#3883](https://github.com/PrefectHQ/fastmcp/pull/3883) - -**Full Changelog**: [v2.14.6...v2.14.7](https://github.com/PrefectHQ/fastmcp/compare/v2.14.6...v2.14.7) - -</Update> - -<Update label="v2.14.6" description="2026-03-27"> - -**[v2.14.6: $Ref Dead Redemption](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.6)** - -v2.14.4 backported `dereference_refs()` but never wired it into the tool schema pipeline — `$ref` and `$defs` were still sent to MCP clients. Now fixed: `compress_schema()` dereferences at both tool schema creation sites, so schemas are fully inlined before reaching clients. - -### Fixes 🐞 -* Updated deprecation URL for V2 by [@SrzStephen](https://github.com/SrzStephen) in [#3109](https://github.com/PrefectHQ/fastmcp/pull/3109) -* Use MemoryStore for OAuth proxy tests by [@SrzStephen](https://github.com/SrzStephen) in [#3111](https://github.com/PrefectHQ/fastmcp/pull/3111) -* fix: wire up dereference_refs() in tool schema pipeline by [@jlowin](https://github.com/jlowin) in [#3170](https://github.com/PrefectHQ/fastmcp/pull/3170) - -**Full Changelog**: [v2.14.5...v2.14.6](https://github.com/PrefectHQ/fastmcp/compare/v2.14.5...v2.14.6) - -</Update> - -<Update label="v2.14.5" description="2026-02-03"> - -**[v2.14.5: Sealed Docket](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.5)** - -Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2. - -## What's Changed -### Enhancements 🔧 -* Bump pydocket to 0.17.2 (memory leak fix) by [@chrisguidry](https://github.com/chrisguidry) in [#2992](https://github.com/PrefectHQ/fastmcp/pull/2992) - -**Full Changelog**: [v2.14.4...v2.14.5](https://github.com/PrefectHQ/fastmcp/compare/v2.14.4...v2.14.5) - -</Update> - -<Update label="v2.14.4" description="2026-01-22"> - -**[v2.14.4: Package Deal](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.4)** - -Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports from 3.x for $ref dereferencing in tool schemas and a task capabilities location fix. - -## What's Changed -### Enhancements 🔧 -* Add release notes for v2.14.2 and v2.14.3 by [@jlowin](https://github.com/jlowin) in [#2851](https://github.com/PrefectHQ/fastmcp/pull/2851) -### Fixes 🐞 -* Backport: Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2861](https://github.com/PrefectHQ/fastmcp/pull/2861) -* Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2874](https://github.com/PrefectHQ/fastmcp/pull/2874) -* Add missing packaging dependency by [@jlowin](https://github.com/jlowin) in [#2989](https://github.com/PrefectHQ/fastmcp/pull/2989) - -**Full Changelog**: [v2.14.3...v2.14.4](https://github.com/PrefectHQ/fastmcp/compare/v2.14.3...v2.14.4) - -</Update> - -<Update label="v2.14.3" description="2026-01-12"> - -**[v2.14.3: Time After Timeout](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.3)** - -Sometimes five seconds just isn't enough. This release fixes an HTTP transport bug that was cutting connections short, along with OAuth and Redis fixes, better ASGI support, and CLI update notifications so you never miss a beat. - -## What's Changed -### Enhancements 🔧 -* Add debug logging for OAuth token expiry diagnostics by [@jlowin](https://github.com/jlowin) in [#2789](https://github.com/PrefectHQ/fastmcp/pull/2789) -* Add CLI update notifications by [@jlowin](https://github.com/jlowin) in [#2839](https://github.com/PrefectHQ/fastmcp/pull/2839) -* Use pip instead of uv pip in upgrade instructions by [@jlowin](https://github.com/jlowin) in [#2841](https://github.com/PrefectHQ/fastmcp/pull/2841) -### Fixes 🐞 -* Backport OAuth token storage TTL fix to release/2.x by [@jlowin](https://github.com/jlowin) in [#2798](https://github.com/PrefectHQ/fastmcp/pull/2798) -* Prefix Redis keys with docket name for ACL isolation (2.x backport) by [@chrisguidry](https://github.com/chrisguidry) in [#2812](https://github.com/PrefectHQ/fastmcp/pull/2812) -* Fix ContextVar propagation for ASGI-mounted servers with tasks by [@chrisguidry](https://github.com/chrisguidry) in [#2843](https://github.com/PrefectHQ/fastmcp/pull/2843) -* Fix HTTP transport timeout defaulting to 5 seconds by [@jlowin](https://github.com/jlowin) in [#2848](https://github.com/PrefectHQ/fastmcp/pull/2848) - -**Full Changelog**: [v2.14.2...v2.14.3](https://github.com/PrefectHQ/fastmcp/compare/v2.14.2...v2.14.3) - -</Update> - -<Update label="v2.14.2" description="2025-12-31"> - -**[v2.14.2: Port Authority](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.2)** - -FastMCP 2.14.2 brings a wave of community contributions safely into the 2.x line. A variety of important fixes backported from 3.0 work improve OpenAPI 3.1 compatibility, MCP spec compliance for output schemas and elicitation, and correct a subtle base_url fallback issue. The CLI now gently reminds you that FastMCP 3.0 is on the horizon. - -## What's Changed -### Enhancements 🔧 -* Pin MCP under 2.x by [@jlowin](https://github.com/jlowin) in [#2709](https://github.com/PrefectHQ/fastmcp/pull/2709) -* Add auth_route parameter to SupabaseProvider by [@EloiZalczer](https://github.com/EloiZalczer) in [#2760](https://github.com/PrefectHQ/fastmcp/pull/2760) -* Update CLI banner with FastMCP 3.0 notice by [@jlowin](https://github.com/jlowin) in [#2765](https://github.com/PrefectHQ/fastmcp/pull/2765) -### Fixes 🐞 -* Let FastMCPError propagate unchanged from managers by [@jlowin](https://github.com/jlowin) in [#2697](https://github.com/PrefectHQ/fastmcp/pull/2697) -* Fix test cleanup for uvicorn 0.39+ context isolation by [@jlowin](https://github.com/jlowin) in [#2696](https://github.com/PrefectHQ/fastmcp/pull/2696) -* Bump pydocket to 0.16.3 to fix worker cleanup race condition by [@chrisguidry](https://github.com/chrisguidry) in [#2700](https://github.com/PrefectHQ/fastmcp/pull/2700) -* Fix Prefect website URL in docs footer by [@mgoldsborough](https://github.com/mgoldsborough) in [#2705](https://github.com/PrefectHQ/fastmcp/pull/2705) -* Fix: resolve root-level $ref in outputSchema for MCP spec compliance by [@majiayu000](https://github.com/majiayu000) in [#2727](https://github.com/PrefectHQ/fastmcp/pull/2727) -* Fix OAuth Proxy resource parameter validation by [@jlowin](https://github.com/jlowin) in [#2763](https://github.com/PrefectHQ/fastmcp/pull/2763) -* Fix openapi_version check to include 3.1 by [@deeleeramone](https://github.com/deeleeramone) in [#2769](https://github.com/PrefectHQ/fastmcp/pull/2769) -* Fix titled enum elicitation schema to comply with MCP spec by [@jlowin](https://github.com/jlowin) in [#2774](https://github.com/PrefectHQ/fastmcp/pull/2774) -* Fix base_url fallback when url is not set by [@bhbs](https://github.com/bhbs) in [#2782](https://github.com/PrefectHQ/fastmcp/pull/2782) -* Lazy import DiskStore to avoid sqlite3 dependency on import by [@jlowin](https://github.com/jlowin) in [#2785](https://github.com/PrefectHQ/fastmcp/pull/2785) -### Docs 📚 -* Add v3 breaking changes notice to README and docs by [@jlowin](https://github.com/jlowin) in [#2713](https://github.com/PrefectHQ/fastmcp/pull/2713) -* Add changelog entries for v2.13.1 through v2.14.1 by [@jlowin](https://github.com/jlowin) in [#2724](https://github.com/PrefectHQ/fastmcp/pull/2724) -* conference to 2.x branch by [@aaazzam](https://github.com/aaazzam) in [#2787](https://github.com/PrefectHQ/fastmcp/pull/2787) - -**Full Changelog**: [v2.14.1...v2.14.2](https://github.com/PrefectHQ/fastmcp/compare/v2.14.1...v2.14.2) - -</Update> - -<Update label="v2.14.1" description="2025-12-15"> - -**[v2.14.1: 'Tis a Gift to Be Sample](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.1)** - -FastMCP 2.14.1 introduces sampling with tools (SEP-1577), enabling servers to pass tools to `ctx.sample()` for agentic workflows where the LLM can automatically execute tool calls in a loop. The new `ctx.sample_step()` method provides single LLM calls that return `SampleStep` objects for custom control flow, while `result_type` enables structured outputs via validated Pydantic models. - -🤖 **AnthropicSamplingHandler** joins the existing OpenAI handler, providing multi-provider sampling support out of the box. - -⚡ **OpenAISamplingHandler promoted** from experimental status—sampling handlers are now production-ready with a unified API. - -## What's Changed -### New Features 🎉 -* Sampling with tools by [@jlowin](https://github.com/jlowin) in [#2538](https://github.com/PrefectHQ/fastmcp/pull/2538) -* Add AnthropicSamplingHandler by [@jlowin](https://github.com/jlowin) in [#2677](https://github.com/PrefectHQ/fastmcp/pull/2677) -### Enhancements 🔧 -* Add Python 3.13 to ubuntu CI by [@jlowin](https://github.com/jlowin) in [#2648](https://github.com/PrefectHQ/fastmcp/pull/2648) -* Remove legacy task initialization workaround by [@jlowin](https://github.com/jlowin) in [#2649](https://github.com/PrefectHQ/fastmcp/pull/2649) -* Consolidate session state reset logic by [@jlowin](https://github.com/jlowin) in [#2651](https://github.com/PrefectHQ/fastmcp/pull/2651) -* Unify SamplingHandler; promote OpenAI from experimental by [@jlowin](https://github.com/jlowin) in [#2656](https://github.com/PrefectHQ/fastmcp/pull/2656) -* Add `tool_names` parameter to mount() for name customization by [@jlowin](https://github.com/jlowin) in [#2660](https://github.com/PrefectHQ/fastmcp/pull/2660) -* Use streamable HTTP client API from MCP SDK by [@jlowin](https://github.com/jlowin) in [#2678](https://github.com/PrefectHQ/fastmcp/pull/2678) -* Deprecate `exclude_args` in favor of Depends() by [@jlowin](https://github.com/jlowin) in [#2693](https://github.com/PrefectHQ/fastmcp/pull/2693) -### Fixes 🐞 -* Fix prompt tasks to return mcp.types.PromptMessage by [@jlowin](https://github.com/jlowin) in [#2650](https://github.com/PrefectHQ/fastmcp/pull/2650) -* Fix Windows test warnings by [@jlowin](https://github.com/jlowin) in [#2653](https://github.com/PrefectHQ/fastmcp/pull/2653) -* Cleanup cancelled connection startup by [@jlowin](https://github.com/jlowin) in [#2679](https://github.com/PrefectHQ/fastmcp/pull/2679) -* Fix tool choice bug in sampling examples by [@shawnthapa](https://github.com/shawnthapa) in [#2686](https://github.com/PrefectHQ/fastmcp/pull/2686) -### Docs 📚 -* Simplify Docket tip wording by [@chrisguidry](https://github.com/chrisguidry) in [#2662](https://github.com/PrefectHQ/fastmcp/pull/2662) -### Other Changes 🦾 -* Bump pydocket to ≥0.15.5 by [@jlowin](https://github.com/jlowin) in [#2694](https://github.com/PrefectHQ/fastmcp/pull/2694) - -## New Contributors -* [@shawnthapa](https://github.com/shawnthapa) made their first contribution in [#2686](https://github.com/PrefectHQ/fastmcp/pull/2686) - -**Full Changelog**: [v2.14.0...v2.14.1](https://github.com/PrefectHQ/fastmcp/compare/v2.14.0...v2.14.1) - -</Update> - -<Update label="v2.14.0" description="2025-12-11"> - -**[v2.14.0: Task and You Shall Receive](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.0)** - -FastMCP 2.14 begins adopting the MCP 2025-11-25 specification, introducing protocol-native background tasks (SEP-1686) that enable long-running operations to report progress without blocking clients. The experimental OpenAPI parser graduates to standard, the `OpenAISamplingHandler` is promoted from experimental, and deprecated APIs accumulated across the 2.x series are removed. - -⏳ **Background Tasks** let you add `task=True` to any async tool decorator to run operations in the background with progress tracking. Powered by [Docket](https://github.com/chrisguidry/docket), an enterprise task scheduler handling millions of concurrent tasks daily—in-memory backends work out-of-the-box, and Redis URLs enable persistence and horizontal scaling. - -🔧 **OpenAPI Parser Promoted** from experimental to standard with improved performance through single-pass schema processing and cleaner abstractions. - -📋 **MCP 2025-11-25 Specification Support** including SSE polling and event resumability (SEP-1699), multi-select enum elicitation schemas (SEP-1330), default values for elicitation (SEP-1034), and tool name validation at registration time (SEP-986). - -## Breaking Changes -- Docket is always enabled; task execution is forbidden through proxies -- Task protocol enabled by default -- Removed deprecated settings, imports, and methods accumulated across 2.x series - -## What's Changed -### New Features 🎉 -* OpenAPI parser is now the default by [@jlowin](https://github.com/jlowin) in [#2583](https://github.com/PrefectHQ/fastmcp/pull/2583) -* Implement SEP-1686: Background Tasks by [@jlowin](https://github.com/jlowin) in [#2550](https://github.com/PrefectHQ/fastmcp/pull/2550) -### Enhancements 🔧 -* Expose InitializeResult in middleware by [@jlowin](https://github.com/jlowin) in [#2562](https://github.com/PrefectHQ/fastmcp/pull/2562) -* Update MCP SDK auth compatibility by [@jlowin](https://github.com/jlowin) in [#2574](https://github.com/PrefectHQ/fastmcp/pull/2574) -* Validate tool names at registration (SEP-986) by [@jlowin](https://github.com/jlowin) in [#2588](https://github.com/PrefectHQ/fastmcp/pull/2588) -* Support SEP-1034 and SEP-1330 for elicitation by [@jlowin](https://github.com/jlowin) in [#2595](https://github.com/PrefectHQ/fastmcp/pull/2595) -* Implement SSE polling (SEP-1699) by [@jlowin](https://github.com/jlowin) in [#2612](https://github.com/PrefectHQ/fastmcp/pull/2612) -* Expose session ID callback by [@jlowin](https://github.com/jlowin) in [#2628](https://github.com/PrefectHQ/fastmcp/pull/2628) -### Fixes 🐞 -* Fix OAuth metadata discovery by [@jlowin](https://github.com/jlowin) in [#2565](https://github.com/PrefectHQ/fastmcp/pull/2565) -* Fix fastapi.cli package structure by [@jlowin](https://github.com/jlowin) in [#2570](https://github.com/PrefectHQ/fastmcp/pull/2570) -* Correct OAuth error codes by [@jlowin](https://github.com/jlowin) in [#2578](https://github.com/PrefectHQ/fastmcp/pull/2578) -* Prevent function signature modification by [@jlowin](https://github.com/jlowin) in [#2590](https://github.com/PrefectHQ/fastmcp/pull/2590) -* Fix proxy client kwargs by [@jlowin](https://github.com/jlowin) in [#2605](https://github.com/PrefectHQ/fastmcp/pull/2605) -* Fix nested server routing by [@jlowin](https://github.com/jlowin) in [#2618](https://github.com/PrefectHQ/fastmcp/pull/2618) -* Use access token expiry fallback by [@jlowin](https://github.com/jlowin) in [#2635](https://github.com/PrefectHQ/fastmcp/pull/2635) -* Handle transport cleanup exceptions by [@jlowin](https://github.com/jlowin) in [#2642](https://github.com/PrefectHQ/fastmcp/pull/2642) -### Docs 📚 -* Add OCI and Supabase integration docs by [@jlowin](https://github.com/jlowin) in [#2580](https://github.com/PrefectHQ/fastmcp/pull/2580) -* Add v2.14.0 upgrade guide by [@jlowin](https://github.com/jlowin) in [#2598](https://github.com/PrefectHQ/fastmcp/pull/2598) -* Rewrite background tasks documentation by [@jlowin](https://github.com/jlowin) in [#2620](https://github.com/PrefectHQ/fastmcp/pull/2620) -* Document read-only tool patterns by [@jlowin](https://github.com/jlowin) in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) - -## New Contributors -11 total contributors including 7 first-time participants. - -**Full Changelog**: [v2.13.3...v2.14.0](https://github.com/PrefectHQ/fastmcp/compare/v2.13.3...v2.14.0) - -</Update> - -<Update label="v2.13.3" description="2025-12-03"> - -**[v2.13.3: Pin-ish Line](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.3)** - -FastMCP 2.13.3 pins `mcp<1.23` as a precautionary measure. MCP SDK 1.23 introduced changes related to the November 25, 2025 MCP protocol update that break certain FastMCP patches and workarounds, particularly around OAuth implementation details. FastMCP 2.14 introduces proper support for the updated protocol and requires `mcp>=1.23`. - -## What's Changed -### Fixes 🐞 -* Pin MCP SDK below 1.23 by [@jlowin](https://github.com/jlowin) in [#2545](https://github.com/PrefectHQ/fastmcp/pull/2545) - -**Full Changelog**: [v2.13.2...v2.13.3](https://github.com/PrefectHQ/fastmcp/compare/v2.13.2...v2.13.3) - -</Update> - -<Update label="v2.13.2" description="2025-12-01"> - -**[v2.13.2: Refreshing Changes](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.2)** - -FastMCP 2.13.2 polishes the authentication stack with improvements to token refresh, scope handling, and multi-instance deployments. Discord was added as a built-in OAuth provider, Azure and Google token handling became more reliable, and proxy classes now properly forward icons and titles. - -## What's Changed -### New Features 🎉 -* Add Discord OAuth provider by [@jlowin](https://github.com/jlowin) in [#2480](https://github.com/PrefectHQ/fastmcp/pull/2480) -### Enhancements 🔧 -* Descope Provider updates for new well-known URLs by [@anvibanga](https://github.com/anvibanga) in [#2465](https://github.com/PrefectHQ/fastmcp/pull/2465) -* Scalekit provider improvements by [@jlowin](https://github.com/jlowin) in [#2472](https://github.com/PrefectHQ/fastmcp/pull/2472) -* Add CSP customization for consent screens by [@jlowin](https://github.com/jlowin) in [#2488](https://github.com/PrefectHQ/fastmcp/pull/2488) -* Add icon support to proxy classes by [@jlowin](https://github.com/jlowin) in [#2495](https://github.com/PrefectHQ/fastmcp/pull/2495) -### Fixes 🐞 -* Google Provider now defaults to refresh token support by [@jlowin](https://github.com/jlowin) in [#2468](https://github.com/PrefectHQ/fastmcp/pull/2468) -* Fix Azure OAuth token refresh with unprefixed scopes by [@jlowin](https://github.com/jlowin) in [#2475](https://github.com/PrefectHQ/fastmcp/pull/2475) -* Prevent `$defs` mutation during tool transforms by [@jlowin](https://github.com/jlowin) in [#2482](https://github.com/PrefectHQ/fastmcp/pull/2482) -* Fix OAuth proxy refresh token storage for multi-instance deployments by [@jlowin](https://github.com/jlowin) in [#2490](https://github.com/PrefectHQ/fastmcp/pull/2490) -* Fix stale token issue after OAuth refresh by [@jlowin](https://github.com/jlowin) in [#2498](https://github.com/PrefectHQ/fastmcp/pull/2498) -* Fix Azure provider OIDC scope handling by [@jlowin](https://github.com/jlowin) in [#2505](https://github.com/PrefectHQ/fastmcp/pull/2505) - -## New Contributors -7 new contributors made their first FastMCP contributions in this release. - -**Full Changelog**: [v2.13.1...v2.13.2](https://github.com/PrefectHQ/fastmcp/compare/v2.13.1...v2.13.2) - -</Update> - -<Update label="v2.13.1" description="2025-11-15"> - -**[v2.13.1: Heavy Meta](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.1)** - -FastMCP 2.13.1 introduces meta parameter support for `ToolResult`, enabling tools to return supplementary metadata alongside results. This supports emerging use cases like OpenAI's Apps SDK. The release also brings improved OAuth functionality with custom token verifiers including a new DebugTokenVerifier, and adds OCI and Supabase authentication providers. - -🏷️ **Meta parameters for ToolResult** enable tools to return supplementary metadata alongside results, supporting patterns like OpenAI's Apps SDK integration. - -🔐 **Custom token verifiers** with DebugTokenVerifier for development, plus Azure Government support through a `base_authority` parameter and Supabase authentication algorithm configuration. - -🔒 **Security fixes** address CVE-2025-61920 through authlib updates and validate Cursor deeplink URLs using safer Windows APIs. - -## What's Changed -### New Features 🎉 -* Add meta parameter support for ToolResult by [@jlowin](https://github.com/jlowin) in [#2350](https://github.com/PrefectHQ/fastmcp/pull/2350) -* Add OCI authentication provider by [@jlowin](https://github.com/jlowin) in [#2365](https://github.com/PrefectHQ/fastmcp/pull/2365) -* Add Supabase authentication provider by [@jlowin](https://github.com/jlowin) in [#2378](https://github.com/PrefectHQ/fastmcp/pull/2378) -### Enhancements 🔧 -* Add custom token verifier support to OIDCProxy by [@jlowin](https://github.com/jlowin) in [#2355](https://github.com/PrefectHQ/fastmcp/pull/2355) -* Add DebugTokenVerifier for development by [@jlowin](https://github.com/jlowin) in [#2362](https://github.com/PrefectHQ/fastmcp/pull/2362) -* Add Azure Government support via base_authority parameter by [@jlowin](https://github.com/jlowin) in [#2385](https://github.com/PrefectHQ/fastmcp/pull/2385) -* Add Supabase authentication algorithm configuration by [@jlowin](https://github.com/jlowin) in [#2392](https://github.com/PrefectHQ/fastmcp/pull/2392) -### Fixes 🐞 -* Security: Update authlib for CVE-2025-61920 by [@jlowin](https://github.com/jlowin) in [#2398](https://github.com/PrefectHQ/fastmcp/pull/2398) -* Validate Cursor deeplink URLs using safer Windows APIs by [@jlowin](https://github.com/jlowin) in [#2405](https://github.com/PrefectHQ/fastmcp/pull/2405) -* Exclude MCP SDK 1.21.1 due to integration test failures by [@jlowin](https://github.com/jlowin) in [#2422](https://github.com/PrefectHQ/fastmcp/pull/2422) - -## New Contributors -18 new contributors joined in this release across 70+ pull requests. - -**Full Changelog**: [v2.13.0...v2.13.1](https://github.com/PrefectHQ/fastmcp/compare/v2.13.0...v2.13.1) - -</Update> - -<Update label="v2.13.0" description="2025-10-25"> - -**[v2.13.0: Cache Me If You Can](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.0)** - -FastMCP 2.13 "Cache Me If You Can" represents a fundamental maturation of the framework. After months of community feedback on authentication and state management, this release delivers the infrastructure FastMCP needs to handle production workloads: persistent storage, response caching, and pragmatic OAuth improvements that reflect real-world deployment challenges. - -💾 **Pluggable storage backends** bring persistent state to FastMCP servers. Built on [py-key-value-aio](https://github.com/strawgate/py-key-value), a new library from FastMCP maintainer Bill Easton ([@strawgate](https://github.com/strawgate)), the storage layer provides encrypted disk storage by default, platform-aware token management, and a simple key-value interface for application state. We're excited to bring this elegantly designed library into the FastMCP ecosystem - it's both powerful and remarkably easy to use, including wrappers to add encryption, TTLs, caching, and more to backends ranging from Elasticsearch, Redis, DynamoDB, filesystem, in-memory, and more! OAuth providers now automatically persist tokens across restarts, and developers can store arbitrary state without reaching for external databases. This foundation enables long-running sessions, cached credentials, and stateful applications built on MCP. - -🔐 **OAuth maturity** brings months of production learnings into the framework. The new consent screen prevents confused deputy and authorization bypass attacks discovered in earlier versions while providing a clean UX with customizable branding. The OAuth proxy now issues its own tokens with automatic key derivation from client secrets, and RFC 7662 token introspection support enables enterprise auth flows. Path prefix mounting enables OAuth-protected servers to integrate into existing web applications under custom paths like `/api`, and MCP 1.17+ compliance with RFC 9728 ensures protocol compatibility. Combined with improved error handling and platform-aware token storage, OAuth is now production-ready and security-hardened for serious applications. - -FastMCP now supports out-of-the-box authentication with: -- **[WorkOS](https://gofastmcp.com/integrations/workos)** and **[AuthKit](https://gofastmcp.com/integrations/authkit)** -- **[GitHub](https://gofastmcp.com/integrations/github)** -- **[Google](https://gofastmcp.com/integrations/google)** -- **[Azure](https://gofastmcp.com/integrations/azure)** (Entra ID) -- **[AWS Cognito](https://gofastmcp.com/integrations/aws-cognito)** -- **[Auth0](https://gofastmcp.com/integrations/auth0)** -- **[Descope](https://gofastmcp.com/integrations/descope)** -- **[Scalekit](https://gofastmcp.com/integrations/scalekit)** -- **[JWTs](https://gofastmcp.com/servers/auth/token-verification#jwt-token-verification)** -- **[RFC 7662 token introspection](https://gofastmcp.com/servers/auth/token-verification#token-introspection-protocol)** - -⚡ **Response Caching Middleware** dramatically improves performance for expensive operations. Cache tool and resource responses with configurable TTLs, reducing redundant API calls and speeding up repeated queries. - -🔄 **Server lifespans** provide proper initialization and cleanup hooks that run once per server instance instead of per client session. This fixes a long-standing source of confusion in the MCP SDK and enables proper resource management for database connections, background tasks, and other server-level state. Note: this is a breaking behavioral change if you were using the `lifespan` parameter. - -✨ **Developer experience improvements** include Pydantic input validation for better type safety, icon support for richer UX, RFC 6570 query parameters for resource templates, improved Context API methods (list_resources, list_prompts, get_prompt), and async file/directory resources. - -This release includes contributions from **20** new contributors and represents the largest feature set in a while. Thank you to everyone who tested preview builds and filed issues - your feedback shaped these improvements! - -**Full Changelog**: [v2.12.5...v2.13.0](https://github.com/PrefectHQ/fastmcp/compare/v2.12.5...v2.13.0) - -</Update> - -<Update label="v2.12.5" description="2025-10-17"> - -**[v2.12.5: Safety Pin](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.5)** - -FastMCP 2.12.5 is a point release that pins the MCP SDK version below 1.17, which introduced a change affecting FastMCP users with auth providers mounted as part of a larger application. This ensures the `.well-known` payload appears in the expected location when using FastMCP authentication providers with composite applications. - -## What's Changed - -### Fixes 🐞 -* Pin MCP SDK version below 1.17 by [@jlowin](https://github.com/jlowin) in [a1b2c3d](https://github.com/PrefectHQ/fastmcp/commit/dab2b316ddc3883b7896a86da21cacb68da01e5c) - -**Full Changelog**: [v2.12.4...v2.12.5](https://github.com/PrefectHQ/fastmcp/compare/v2.12.4...v2.12.5) - -</Update> - -<Update label="v2.12.4" description="2025-09-26"> - -**[v2.12.4: OIDC What You Did There](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.4)** - -FastMCP 2.12.4 adds comprehensive OIDC support and expands authentication options with AWS Cognito and Descope providers. The release also includes improvements to logging middleware, URL handling for nested resources, persistent OAuth client registration storage, and various fixes to the experimental OpenAPI parser. - -## What's Changed -### New Features 🎉 -* feat: Add support for OIDC configuration by [@ruhulio](https://github.com/ruhulio) in [#1817](https://github.com/PrefectHQ/fastmcp/pull/1817) -### Enhancements 🔧 -* feat: Move the Starlette context middleware to the front by [@akkuman](https://github.com/akkuman) in [#1812](https://github.com/PrefectHQ/fastmcp/pull/1812) -* Refactor Logging and Structured Logging Middleware by [@strawgate](https://github.com/strawgate) in [#1805](https://github.com/PrefectHQ/fastmcp/pull/1805) -* Update pull_request_template.md by [@jlowin](https://github.com/jlowin) in [#1824](https://github.com/PrefectHQ/fastmcp/pull/1824) -* chore: Set redirect_path default in function by [@ruhulio](https://github.com/ruhulio) in [#1833](https://github.com/PrefectHQ/fastmcp/pull/1833) -* feat: Set instructions in code by [@attiks](https://github.com/attiks) in [#1838](https://github.com/PrefectHQ/fastmcp/pull/1838) -* Automatically Create inline Snapshots by [@strawgate](https://github.com/strawgate) in [#1779](https://github.com/PrefectHQ/fastmcp/pull/1779) -* chore: Cleanup Auth0 redirect_path initialization by [@ruhulio](https://github.com/ruhulio) in [#1842](https://github.com/PrefectHQ/fastmcp/pull/1842) -* feat: Add support for Descope Authentication by [@anvibanga](https://github.com/anvibanga) in [#1853](https://github.com/PrefectHQ/fastmcp/pull/1853) -* Update descope version badges by [@jlowin](https://github.com/jlowin) in [#1870](https://github.com/PrefectHQ/fastmcp/pull/1870) -* Update welcome images by [@jlowin](https://github.com/jlowin) in [#1884](https://github.com/PrefectHQ/fastmcp/pull/1884) -* Fix rounded edges of image by [@jlowin](https://github.com/jlowin) in [#1886](https://github.com/PrefectHQ/fastmcp/pull/1886) -* optimize test suite by [@zzstoatzz](https://github.com/zzstoatzz) in [#1893](https://github.com/PrefectHQ/fastmcp/pull/1893) -* Enhancement: client completions support context_arguments by [@isijoe](https://github.com/isijoe) in [#1906](https://github.com/PrefectHQ/fastmcp/pull/1906) -* Update Descope icon by [@anvibanga](https://github.com/anvibanga) in [#1912](https://github.com/PrefectHQ/fastmcp/pull/1912) -* Add AWS Cognito OAuth Provider for Enterprise Authentication by [@stephaneberle9](https://github.com/stephaneberle9) in [#1873](https://github.com/PrefectHQ/fastmcp/pull/1873) -* Fix typos discovered by codespell by [@cclauss](https://github.com/cclauss) in [#1922](https://github.com/PrefectHQ/fastmcp/pull/1922) -* Use lowercase namespace for fastmcp logger by [@jlowin](https://github.com/jlowin) in [#1791](https://github.com/PrefectHQ/fastmcp/pull/1791) -### Fixes 🐞 -* Update quickstart.mdx by [@radi-dev](https://github.com/radi-dev) in [#1821](https://github.com/PrefectHQ/fastmcp/pull/1821) -* Remove extraneous union import by [@jlowin](https://github.com/jlowin) in [#1823](https://github.com/PrefectHQ/fastmcp/pull/1823) -* Delay import of Provider classes until FastMCP Server Creation by [@strawgate](https://github.com/strawgate) in [#1820](https://github.com/PrefectHQ/fastmcp/pull/1820) -* fix: correct documentation link in deprecation warning by [@strawgate](https://github.com/strawgate) in [#1828](https://github.com/PrefectHQ/fastmcp/pull/1828) -* fix: Increase default 3s timeout on Pytest by [@dacamposol](https://github.com/dacamposol) in [#1866](https://github.com/PrefectHQ/fastmcp/pull/1866) -* fix: Improve URL handling in OIDCConfiguration by [@ruhulio](https://github.com/ruhulio) in [#1850](https://github.com/PrefectHQ/fastmcp/pull/1850) -* fix: correct typing for on_read_resource middleware method by [@strawgate](https://github.com/strawgate) in [#1858](https://github.com/PrefectHQ/fastmcp/pull/1858) -* feat(experimental/openapi): replace $ref in additionalProperties; add tests by [@jlowin](https://github.com/jlowin) in [#1735](https://github.com/PrefectHQ/fastmcp/pull/1735) -* Honor client supplied scopes during registration by [@dmikusa](https://github.com/dmikusa) in [#1860](https://github.com/PrefectHQ/fastmcp/pull/1860) -* Fix: FastAPI list parameter parsing in experimental OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#1834](https://github.com/PrefectHQ/fastmcp/pull/1834) -* Add log level support for stdio and HTTP transports by [@jlowin](https://github.com/jlowin) in [#1840](https://github.com/PrefectHQ/fastmcp/pull/1840) -* Fix OAuth pre-flight check to accept HTTP 200 responses by [@jlowin](https://github.com/jlowin) in [#1874](https://github.com/PrefectHQ/fastmcp/pull/1874) -* Fix: Preserve OpenAPI parameter descriptions in experimental parser by [@shlomo666](https://github.com/shlomo666) in [#1877](https://github.com/PrefectHQ/fastmcp/pull/1877) -* Add persistent storage for OAuth client registrations by [@jlowin](https://github.com/jlowin) in [#1879](https://github.com/PrefectHQ/fastmcp/pull/1879) -* docs: update release dates based on github releases by [@lodu](https://github.com/lodu) in [#1890](https://github.com/PrefectHQ/fastmcp/pull/1890) -* Small updates to Sampling types by [@strawgate](https://github.com/strawgate) in [#1882](https://github.com/PrefectHQ/fastmcp/pull/1882) -* remove lockfile smart_home example by [@zzstoatzz](https://github.com/zzstoatzz) in [#1892](https://github.com/PrefectHQ/fastmcp/pull/1892) -* Fix: Remove JSON schema title metadata while preserving parameters named 'title' by [@jlowin](https://github.com/jlowin) in [#1872](https://github.com/PrefectHQ/fastmcp/pull/1872) -* Fix: get_resource_url nested URL handling by [@raphael-linx](https://github.com/raphael-linx) in [#1914](https://github.com/PrefectHQ/fastmcp/pull/1914) -* Clean up code for creating the resource url by [@jlowin](https://github.com/jlowin) in [#1916](https://github.com/PrefectHQ/fastmcp/pull/1916) -* Fix route count logging in OpenAPI server by [@zzstoatzz](https://github.com/zzstoatzz) in [#1928](https://github.com/PrefectHQ/fastmcp/pull/1928) -### Docs 📚 -* docs: make Gemini CLI integration discoverable by [@jackwotherspoon](https://github.com/jackwotherspoon) in [#1827](https://github.com/PrefectHQ/fastmcp/pull/1827) -* docs: update NEW tags for AI assistant integrations by [@jackwotherspoon](https://github.com/jackwotherspoon) in [#1829](https://github.com/PrefectHQ/fastmcp/pull/1829) -* Update wordmark by [@jlowin](https://github.com/jlowin) in [#1832](https://github.com/PrefectHQ/fastmcp/pull/1832) -* docs: improve OAuth and OIDC Proxy documentation by [@jlowin](https://github.com/jlowin) in [#1880](https://github.com/PrefectHQ/fastmcp/pull/1880) -* Update readme + welcome docs by [@jlowin](https://github.com/jlowin) in [#1883](https://github.com/PrefectHQ/fastmcp/pull/1883) -* Update dark mode image in README by [@jlowin](https://github.com/jlowin) in [#1885](https://github.com/PrefectHQ/fastmcp/pull/1885) - -## New Contributors -* [@radi-dev](https://github.com/radi-dev) made their first contribution in [#1821](https://github.com/PrefectHQ/fastmcp/pull/1821) -* [@akkuman](https://github.com/akkuman) made their first contribution in [#1812](https://github.com/PrefectHQ/fastmcp/pull/1812) -* [@ruhulio](https://github.com/ruhulio) made their first contribution in [#1817](https://github.com/PrefectHQ/fastmcp/pull/1817) -* [@attiks](https://github.com/attiks) made their first contribution in [#1838](https://github.com/PrefectHQ/fastmcp/pull/1838) -* [@anvibanga](https://github.com/anvibanga) made their first contribution in [#1853](https://github.com/PrefectHQ/fastmcp/pull/1853) -* [@shlomo666](https://github.com/shlomo666) made their first contribution in [#1877](https://github.com/PrefectHQ/fastmcp/pull/1877) -* [@lodu](https://github.com/lodu) made their first contribution in [#1890](https://github.com/PrefectHQ/fastmcp/pull/1890) -* [@isijoe](https://github.com/isijoe) made their first contribution in [#1906](https://github.com/PrefectHQ/fastmcp/pull/1906) -* [@raphael-linx](https://github.com/raphael-linx) made their first contribution in [#1914](https://github.com/PrefectHQ/fastmcp/pull/1914) -* [@stephaneberle9](https://github.com/stephaneberle9) made their first contribution in [#1873](https://github.com/PrefectHQ/fastmcp/pull/1873) -* [@cclauss](https://github.com/cclauss) made their first contribution in [#1922](https://github.com/PrefectHQ/fastmcp/pull/1922) - -**Full Changelog**: [v2.12.3...v2.12.4](https://github.com/PrefectHQ/fastmcp/compare/v2.12.3...v2.12.4) - -</Update> - -<Update label="v2.12.3" description="2025-09-17"> - -**[v2.12.3: Double Time](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.3)** - -FastMCP 2.12.3 focuses on performance and developer experience improvements based on community feedback. This release includes optimized auth provider imports that reduce server startup time, enhanced OIDC authentication flows with proper token management, and several reliability fixes for OAuth proxy configurations. The addition of automatic inline snapshot creation significantly improves the testing experience for contributors. - -## What's Changed -### New Features 🎉 -* feat: Support setting MCP log level via transport configuration by [@jlowin](https://github.com/jlowin) in [#1756](https://github.com/PrefectHQ/fastmcp/pull/1756) -### Enhancements 🔧 -* Add client-side auth support for mcp install cursor command by [@jlowin](https://github.com/jlowin) in [#1747](https://github.com/PrefectHQ/fastmcp/pull/1747) -* Automatically Create inline Snapshots by [@strawgate](https://github.com/strawgate) in [#1779](https://github.com/PrefectHQ/fastmcp/pull/1779) -* Use lowercase namespace for fastmcp logger by [@jlowin](https://github.com/jlowin) in [#1791](https://github.com/PrefectHQ/fastmcp/pull/1791) -### Fixes 🐞 -* fix: correct merge mistake during auth0 refactor by [@strawgate](https://github.com/strawgate) in [#1742](https://github.com/PrefectHQ/fastmcp/pull/1742) -* Remove extraneous union import by [@jlowin](https://github.com/jlowin) in [#1823](https://github.com/PrefectHQ/fastmcp/pull/1823) -* Delay import of Provider classes until FastMCP Server Creation by [@strawgate](https://github.com/strawgate) in [#1820](https://github.com/PrefectHQ/fastmcp/pull/1820) -* fix: refactor OIDC configuration provider for proper token management by [@strawgate](https://github.com/strawgate) in [#1751](https://github.com/PrefectHQ/fastmcp/pull/1751) -* Fix smart_home example imports by [@strawgate](https://github.com/strawgate) in [#1753](https://github.com/PrefectHQ/fastmcp/pull/1753) -* fix: correct oauth proxy initialization of client by [@strawgate](https://github.com/strawgate) in [#1759](https://github.com/PrefectHQ/fastmcp/pull/1759) -* Fix: return empty string when prompts have no arguments by [@jlowin](https://github.com/jlowin) in [#1766](https://github.com/PrefectHQ/fastmcp/pull/1766) -* Fix async server callbacks by [@strawgate](https://github.com/strawgate) in [#1774](https://github.com/PrefectHQ/fastmcp/pull/1774) -* Fix error when retrieving Completion API errors by [@strawgate](https://github.com/strawgate) in [#1785](https://github.com/PrefectHQ/fastmcp/pull/1785) -* fix: correct documentation link in deprecation warning by [@strawgate](https://github.com/strawgate) in [#1828](https://github.com/PrefectHQ/fastmcp/pull/1828) -### Docs 📚 -* Add migration docs for 2.12 by [@jlowin](https://github.com/jlowin) in [#1745](https://github.com/PrefectHQ/fastmcp/pull/1745) -* Update docs for default sampling implementation to mention OpenAI API Key by [@strawgate](https://github.com/strawgate) in [#1763](https://github.com/PrefectHQ/fastmcp/pull/1763) -* Add tip about sampling prompts and user_context to sampling documentation by [@jlowin](https://github.com/jlowin) in [#1764](https://github.com/PrefectHQ/fastmcp/pull/1764) -* Update quickstart.mdx by [@radi-dev](https://github.com/radi-dev) in [#1821](https://github.com/PrefectHQ/fastmcp/pull/1821) -### Other Changes 🦾 -* Replace Marvin with Claude Code in CI by [@jlowin](https://github.com/jlowin) in [#1800](https://github.com/PrefectHQ/fastmcp/pull/1800) -* Refactor logging and structured logging middleware by [@strawgate](https://github.com/strawgate) in [#1805](https://github.com/PrefectHQ/fastmcp/pull/1805) -* feat: Move the Starlette context middleware to the front by [@akkuman](https://github.com/akkuman) in [#1812](https://github.com/PrefectHQ/fastmcp/pull/1812) -* feat: Add support for OIDC configuration by [@ruhulio](https://github.com/ruhulio) in [#1817](https://github.com/PrefectHQ/fastmcp/pull/1817) - -## New Contributors -* [@radi-dev](https://github.com/radi-dev) made their first contribution in [#1821](https://github.com/PrefectHQ/fastmcp/pull/1821) -* [@akkuman](https://github.com/akkuman) made their first contribution in [#1812](https://github.com/PrefectHQ/fastmcp/pull/1812) -* [@ruhulio](https://github.com/ruhulio) made their first contribution in [#1817](https://github.com/PrefectHQ/fastmcp/pull/1817) - -**Full Changelog**: [v2.12.2...v2.12.3](https://github.com/PrefectHQ/fastmcp/compare/v2.12.2...v2.12.3) - -</Update> - -<Update label="v2.12.2" description="2025-09-03"> - -**[v2.12.2: Perchance to Stream](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.2)** - -This is a hotfix for a bug where the `streamable-http` transport was not recognized as a valid option in `fastmcp.json` configuration files, despite being supported by the CLI. This resulted in a parsing error when the CLI arguments were merged against the configuration spec. - -## What's Changed -### Fixes 🐞 -* Fix streamable-http transport validation in fastmcp.json config by [@jlowin](https://github.com/jlowin) in [#1739](https://github.com/PrefectHQ/fastmcp/pull/1739) - -**Full Changelog**: [v2.12.1...v2.12.2](https://github.com/PrefectHQ/fastmcp/compare/v2.12.1...v2.12.2) - -</Update> - -<Update label="v2.12.1" description="2025-09-03"> - -**[v2.12.1: OAuth to Joy](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.1)** - -FastMCP 2.12.1 strengthens the OAuth proxy implementation based on extensive community testing and feedback. This release improves client storage reliability, adds PKCE forwarding for enhanced security, introduces configurable token endpoint authentication methods, and expands scope handling—all addressing real-world integration challenges discovered since 2.12.0. The enhanced test suite with mock providers ensures these improvements are robust and maintainable. - -## Breaking Changes -- **OAuth Proxy**: Users of built-in IDP integrations should note that `resource_server_url` has been renamed to `base_url` for clarity and consistency - -## What's Changed -### Enhancements 🔧 -* Make openai dependency optional by [@jlowin](https://github.com/jlowin) in [#1701](https://github.com/PrefectHQ/fastmcp/pull/1701) -* Remove orphaned OAuth proxy code by [@jlowin](https://github.com/jlowin) in [#1722](https://github.com/PrefectHQ/fastmcp/pull/1722) -* Expose valid scopes from OAuthProxy metadata by [@dmikusa](https://github.com/dmikusa) in [#1717](https://github.com/PrefectHQ/fastmcp/pull/1717) -* OAuth proxy PKCE forwarding by [@jlowin](https://github.com/jlowin) in [#1733](https://github.com/PrefectHQ/fastmcp/pull/1733) -* Add token_endpoint_auth_method parameter to OAuthProxy by [@jlowin](https://github.com/jlowin) in [#1736](https://github.com/PrefectHQ/fastmcp/pull/1736) -* Clean up and enhance OAuth proxy tests with mock provider by [@jlowin](https://github.com/jlowin) in [#1738](https://github.com/PrefectHQ/fastmcp/pull/1738) -### Fixes 🐞 -* refactor: replace auth provider registry with ImportString by [@jlowin](https://github.com/jlowin) in [#1710](https://github.com/PrefectHQ/fastmcp/pull/1710) -* Fix OAuth resource URL handling and WWW-Authenticate header by [@jlowin](https://github.com/jlowin) in [#1706](https://github.com/PrefectHQ/fastmcp/pull/1706) -* Fix OAuth proxy client storage and add retry logic by [@jlowin](https://github.com/jlowin) in [#1732](https://github.com/PrefectHQ/fastmcp/pull/1732) -### Docs 📚 -* Fix documentation: use StreamableHttpTransport for headers in testing by [@jlowin](https://github.com/jlowin) in [#1702](https://github.com/PrefectHQ/fastmcp/pull/1702) -* docs: add performance warnings for mounted servers and proxies by [@strawgate](https://github.com/strawgate) in [#1669](https://github.com/PrefectHQ/fastmcp/pull/1669) -* Update documentation around scopes for google by [@jlowin](https://github.com/jlowin) in [#1703](https://github.com/PrefectHQ/fastmcp/pull/1703) -* Add deployment information to quickstart by [@seanpwlms](https://github.com/seanpwlms) in [#1433](https://github.com/PrefectHQ/fastmcp/pull/1433) -* Update quickstart by [@jlowin](https://github.com/jlowin) in [#1728](https://github.com/PrefectHQ/fastmcp/pull/1728) -* Add development docs for FastMCP by [@jlowin](https://github.com/jlowin) in [#1719](https://github.com/PrefectHQ/fastmcp/pull/1719) -### Other Changes 🦾 -* Set generics without bounds to default=Any by [@strawgate](https://github.com/strawgate) in [#1648](https://github.com/PrefectHQ/fastmcp/pull/1648) - -## New Contributors -* [@dmikusa](https://github.com/dmikusa) made their first contribution in [#1717](https://github.com/PrefectHQ/fastmcp/pull/1717) -* [@seanpwlms](https://github.com/seanpwlms) made their first contribution in [#1433](https://github.com/PrefectHQ/fastmcp/pull/1433) - -**Full Changelog**: [v2.12.0...v2.12.1](https://github.com/PrefectHQ/fastmcp/compare/v2.12.0...v2.12.1) - -</Update> - -<Update label="v2.12.0" description="2025-08-31"> - -**[v2.12.0: Auth to the Races](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.0)** - -FastMCP 2.12 represents one of our most significant releases to date, both in scope and community involvement. After extensive testing and iteration with the community, we're shipping major improvements to authentication, configuration, and MCP feature adoption. - -🔐 **OAuth Proxy for Broader Provider Support** addresses a fundamental challenge: while MCP requires Dynamic Client Registration (DCR), many popular OAuth providers don't support it. The new OAuth proxy bridges this gap, enabling FastMCP servers to authenticate with providers like GitHub, Google, WorkOS, and Azure through minimal configuration. These native integrations ship today, with more providers planned based on community needs. - -📋 **Declarative JSON Configuration** introduces a standardized, portable way to describe and deploy MCP servers. The `fastmcp.json` configuration file becomes the single source of truth for dependencies, transport settings, entrypoints, and server metadata. This foundation sets the stage for future capabilities like transformations and remote sources, moving toward a world where MCP servers are as portable and shareable as container images. - -🧠 **Sampling API Fallback** tackles the chicken-and-egg problem limiting adoption of advanced MCP features. Sampling—where servers request LLM completions from clients—is powerful but underutilized due to limited client support. FastMCP now lets server authors define fallback handlers that generate sampling completions server-side when clients don't support the feature, encouraging adoption while maintaining compatibility. - -This release took longer than usual to ship, and for good reason: the community's aggressive testing and feedback on the authentication system helped us reach a level of stability we're confident in. There's certainly more work ahead, but these foundations position FastMCP to handle increasingly complex use cases while remaining approachable for developers. - -Thank you to our new contributors and everyone who tested preview builds. Your feedback directly shaped these features. - -## What's Changed -### New Features 🎉 -* Add OAuth proxy that allows authentication with social IDPs without DCR support by [@jlowin](https://github.com/jlowin) in [#1434](https://github.com/PrefectHQ/fastmcp/pull/1434) -* feat: introduce declarative JSON configuration system by [@jlowin](https://github.com/jlowin) in [#1517](https://github.com/PrefectHQ/fastmcp/pull/1517) -* ✨ Fallback to a Completions API when Sampling is not available by [@strawgate](https://github.com/strawgate) in [#1145](https://github.com/PrefectHQ/fastmcp/pull/1145) -* Implement typed source system for FastMCP declarative configuration by [@jlowin](https://github.com/jlowin) in [#1607](https://github.com/PrefectHQ/fastmcp/pull/1607) -### Enhancements 🔧 -* Support importing custom_route endpoints when mounting servers by [@jlowin](https://github.com/jlowin) in [#1470](https://github.com/PrefectHQ/fastmcp/pull/1470) -* Remove unnecessary asserts by [@jlowin](https://github.com/jlowin) in [#1484](https://github.com/PrefectHQ/fastmcp/pull/1484) -* Add Claude issue triage by [@jlowin](https://github.com/jlowin) in [#1510](https://github.com/PrefectHQ/fastmcp/pull/1510) -* Inline dedupe prompt by [@jlowin](https://github.com/jlowin) in [#1512](https://github.com/PrefectHQ/fastmcp/pull/1512) -* Improve stdio and mcp_config clean-up by [@strawgate](https://github.com/strawgate) in [#1444](https://github.com/PrefectHQ/fastmcp/pull/1444) -* involve kwargs to pass parameters on creating RichHandler for logging customization. by [@itaru2622](https://github.com/itaru2622) in [#1504](https://github.com/PrefectHQ/fastmcp/pull/1504) -* Move SDK docs generation to post-merge workflow by [@jlowin](https://github.com/jlowin) in [#1513](https://github.com/PrefectHQ/fastmcp/pull/1513) -* Improve label triage guidance by [@jlowin](https://github.com/jlowin) in [#1516](https://github.com/PrefectHQ/fastmcp/pull/1516) -* Add code review guidelines for agents by [@jlowin](https://github.com/jlowin) in [#1520](https://github.com/PrefectHQ/fastmcp/pull/1520) -* Remove trailing slash in unit tests by [@jlowin](https://github.com/jlowin) in [#1535](https://github.com/PrefectHQ/fastmcp/pull/1535) -* Update OAuth callback UI branding by [@jlowin](https://github.com/jlowin) in [#1536](https://github.com/PrefectHQ/fastmcp/pull/1536) -* Fix Marvin workflow to support development tools by [@jlowin](https://github.com/jlowin) in [#1537](https://github.com/PrefectHQ/fastmcp/pull/1537) -* Add mounted_components_raise_on_load_error setting for debugging by [@jlowin](https://github.com/jlowin) in [#1534](https://github.com/PrefectHQ/fastmcp/pull/1534) -* feat: Add --workspace flag to fastmcp install cursor by [@jlowin](https://github.com/jlowin) in [#1522](https://github.com/PrefectHQ/fastmcp/pull/1522) -* switch from `pyright` to `ty` by [@zzstoatzz](https://github.com/zzstoatzz) in [#1545](https://github.com/PrefectHQ/fastmcp/pull/1545) -* feat: trigger Marvin workflow on PR body content by [@jlowin](https://github.com/jlowin) in [#1549](https://github.com/PrefectHQ/fastmcp/pull/1549) -* Add WorkOS and Azure OAuth providers by [@jlowin](https://github.com/jlowin) in [#1550](https://github.com/PrefectHQ/fastmcp/pull/1550) -* Adjust timeout for slow MCP Server shutdown test by [@strawgate](https://github.com/strawgate) in [#1561](https://github.com/PrefectHQ/fastmcp/pull/1561) -* Update banner by [@jlowin](https://github.com/jlowin) in [#1567](https://github.com/PrefectHQ/fastmcp/pull/1567) -* Added import of AuthProxy to auth __init__ by [@KaliszS](https://github.com/KaliszS) in [#1568](https://github.com/PrefectHQ/fastmcp/pull/1568) -* Add configurable redirect URI validation for OAuth providers by [@jlowin](https://github.com/jlowin) in [#1582](https://github.com/PrefectHQ/fastmcp/pull/1582) -* Remove invalid-argument-type ignore and fix type errors by [@jlowin](https://github.com/jlowin) in [#1588](https://github.com/PrefectHQ/fastmcp/pull/1588) -* Remove generate-schema from public CLI by [@jlowin](https://github.com/jlowin) in [#1591](https://github.com/PrefectHQ/fastmcp/pull/1591) -* Skip flaky windows test / mulit-client garbage collection by [@jlowin](https://github.com/jlowin) in [#1592](https://github.com/PrefectHQ/fastmcp/pull/1592) -* Add setting to disable logging configuration by [@isra17](https://github.com/isra17) in [#1575](https://github.com/PrefectHQ/fastmcp/pull/1575) -* Improve debug logging for nested Servers / Clients by [@strawgate](https://github.com/strawgate) in [#1604](https://github.com/PrefectHQ/fastmcp/pull/1604) -* Add GitHub pull request template by [@strawgate](https://github.com/strawgate) in [#1581](https://github.com/PrefectHQ/fastmcp/pull/1581) -* chore: Automate docs and schema updates via PRs by [@jlowin](https://github.com/jlowin) in [#1611](https://github.com/PrefectHQ/fastmcp/pull/1611) -* Experiment with haiku for limited workflows by [@jlowin](https://github.com/jlowin) in [#1613](https://github.com/PrefectHQ/fastmcp/pull/1613) -* feat: Improve GitHub workflow automation for schema and SDK docs by [@jlowin](https://github.com/jlowin) in [#1615](https://github.com/PrefectHQ/fastmcp/pull/1615) -* Consolidate server loading logic into FileSystemSource by [@jlowin](https://github.com/jlowin) in [#1614](https://github.com/PrefectHQ/fastmcp/pull/1614) -* Prevent Haiku Marvin from commenting when there are no duplicates by [@jlowin](https://github.com/jlowin) in [#1622](https://github.com/PrefectHQ/fastmcp/pull/1622) -* chore: Add clarifying note to automated PR bodies by [@jlowin](https://github.com/jlowin) in [#1623](https://github.com/PrefectHQ/fastmcp/pull/1623) -* feat: introduce inline snapshots by [@strawgate](https://github.com/strawgate) in [#1605](https://github.com/PrefectHQ/fastmcp/pull/1605) -* Improve fastmcp.json environment configuration and project-based deployments by [@jlowin](https://github.com/jlowin) in [#1631](https://github.com/PrefectHQ/fastmcp/pull/1631) -* fix: allow passing query params in OAuthProxy upstream authorization url by [@danb27](https://github.com/danb27) in [#1630](https://github.com/PrefectHQ/fastmcp/pull/1630) -* Support multiple --with-editable flags in CLI commands by [@jlowin](https://github.com/jlowin) in [#1634](https://github.com/PrefectHQ/fastmcp/pull/1634) -* feat: support comma separated oauth scopes by [@jlowin](https://github.com/jlowin) in [#1642](https://github.com/PrefectHQ/fastmcp/pull/1642) -* Add allowed_client_redirect_uris to OAuth provider subclasses by [@jlowin](https://github.com/jlowin) in [#1662](https://github.com/PrefectHQ/fastmcp/pull/1662) -* Consolidate CLI config parsing and prevent infinite loops by [@jlowin](https://github.com/jlowin) in [#1660](https://github.com/PrefectHQ/fastmcp/pull/1660) -* Internal refactor: mcp server config by [@jlowin](https://github.com/jlowin) in [#1672](https://github.com/PrefectHQ/fastmcp/pull/1672) -* Refactor Environment to support multiple runtime types by [@jlowin](https://github.com/jlowin) in [#1673](https://github.com/PrefectHQ/fastmcp/pull/1673) -* Add type field to Environment base class by [@jlowin](https://github.com/jlowin) in [#1676](https://github.com/PrefectHQ/fastmcp/pull/1676) -### Fixes 🐞 -* Fix breaking change: restore output_schema=False compatibility by [@jlowin](https://github.com/jlowin) in [#1482](https://github.com/PrefectHQ/fastmcp/pull/1482) -* Fix #1506: Update tool filtering documentation from _meta to meta by [@maybenotconnor](https://github.com/maybenotconnor) in [#1511](https://github.com/PrefectHQ/fastmcp/pull/1511) -* Fix pytest warnings by [@jlowin](https://github.com/jlowin) in [#1559](https://github.com/PrefectHQ/fastmcp/pull/1559) -* nest schemas under assets by [@jlowin](https://github.com/jlowin) in [#1593](https://github.com/PrefectHQ/fastmcp/pull/1593) -* Skip flaky windows test by [@jlowin](https://github.com/jlowin) in [#1596](https://github.com/PrefectHQ/fastmcp/pull/1596) -* ACTUALLY move schemas to fastmcp.json by [@jlowin](https://github.com/jlowin) in [#1597](https://github.com/PrefectHQ/fastmcp/pull/1597) -* Fix and centralize CLI path resolution by [@jlowin](https://github.com/jlowin) in [#1590](https://github.com/PrefectHQ/fastmcp/pull/1590) -* Remove client info modifications by [@jlowin](https://github.com/jlowin) in [#1620](https://github.com/PrefectHQ/fastmcp/pull/1620) -* Fix $defs being discarded in input schema of transformed tool by [@pldesch-chift](https://github.com/pldesch-chift) in [#1578](https://github.com/PrefectHQ/fastmcp/pull/1578) -* Fix enum elicitation to use inline schemas for MCP compatibility by [@jlowin](https://github.com/jlowin) in [#1632](https://github.com/PrefectHQ/fastmcp/pull/1632) -* Reuse session for `StdioTransport` in `Client.new` by [@strawgate](https://github.com/strawgate) in [#1635](https://github.com/PrefectHQ/fastmcp/pull/1635) -* Feat: Configurable LoggingMiddleware payload serialization by [@vl-kp](https://github.com/vl-kp) in [#1636](https://github.com/PrefectHQ/fastmcp/pull/1636) -* Fix OAuth redirect URI validation for DCR compatibility by [@jlowin](https://github.com/jlowin) in [#1661](https://github.com/PrefectHQ/fastmcp/pull/1661) -* Add default scope handling in OAuth proxy by [@romanusyk](https://github.com/romanusyk) in [#1667](https://github.com/PrefectHQ/fastmcp/pull/1667) -* Fix OAuth token expiry handling by [@jlowin](https://github.com/jlowin) in [#1671](https://github.com/PrefectHQ/fastmcp/pull/1671) -* Add resource_server_url parameter to OAuth proxy providers by [@jlowin](https://github.com/jlowin) in [#1682](https://github.com/PrefectHQ/fastmcp/pull/1682) -### Breaking Changes 🛫 -* Enhance inspect command with structured output and format options by [@jlowin](https://github.com/jlowin) in [#1481](https://github.com/PrefectHQ/fastmcp/pull/1481) -### Docs 📚 -* Update changelog by [@jlowin](https://github.com/jlowin) in [#1453](https://github.com/PrefectHQ/fastmcp/pull/1453) -* Update banner by [@jlowin](https://github.com/jlowin) in [#1472](https://github.com/PrefectHQ/fastmcp/pull/1472) -* Update logo files by [@jlowin](https://github.com/jlowin) in [#1473](https://github.com/PrefectHQ/fastmcp/pull/1473) -* Update deployment docs by [@jlowin](https://github.com/jlowin) in [#1486](https://github.com/PrefectHQ/fastmcp/pull/1486) -* Update FastMCP Cloud screenshot by [@jlowin](https://github.com/jlowin) in [#1487](https://github.com/PrefectHQ/fastmcp/pull/1487) -* Update authentication note in docs by [@jlowin](https://github.com/jlowin) in [#1488](https://github.com/PrefectHQ/fastmcp/pull/1488) -* chore: Update installation.mdx version snippet by [@thomas-te](https://github.com/thomas-te) in [#1496](https://github.com/PrefectHQ/fastmcp/pull/1496) -* Update fastmcp cloud server requirements by [@jlowin](https://github.com/jlowin) in [#1497](https://github.com/PrefectHQ/fastmcp/pull/1497) -* Fix oauth pyright type checking by [@strawgate](https://github.com/strawgate) in [#1498](https://github.com/PrefectHQ/fastmcp/pull/1498) -* docs: Fix type annotation in return value documentation by [@MaikelVeen](https://github.com/MaikelVeen) in [#1499](https://github.com/PrefectHQ/fastmcp/pull/1499) -* Fix PromptMessage usage in docs example by [@jlowin](https://github.com/jlowin) in [#1515](https://github.com/PrefectHQ/fastmcp/pull/1515) -* Create CODE_OF_CONDUCT.md by [@jlowin](https://github.com/jlowin) in [#1523](https://github.com/PrefectHQ/fastmcp/pull/1523) -* Fixed wrong import path in new docs page by [@KaliszS](https://github.com/KaliszS) in [#1538](https://github.com/PrefectHQ/fastmcp/pull/1538) -* Document symmetric key JWT verification support by [@jlowin](https://github.com/jlowin) in [#1586](https://github.com/PrefectHQ/fastmcp/pull/1586) -* Update fastmcp.json schema path by [@jlowin](https://github.com/jlowin) in [#1595](https://github.com/PrefectHQ/fastmcp/pull/1595) -### Dependencies 📦 -* Bump actions/create-github-app-token from 1 to 2 by [@dependabot](https://github.com/dependabot)[bot] in [#1436](https://github.com/PrefectHQ/fastmcp/pull/1436) -* Bump astral-sh/setup-uv from 4 to 6 by [@dependabot](https://github.com/dependabot)[bot] in [#1532](https://github.com/PrefectHQ/fastmcp/pull/1532) -* Bump actions/checkout from 4 to 5 by [@dependabot](https://github.com/dependabot)[bot] in [#1533](https://github.com/PrefectHQ/fastmcp/pull/1533) -### Other Changes 🦾 -* Add dedupe workflow by [@jlowin](https://github.com/jlowin) in [#1454](https://github.com/PrefectHQ/fastmcp/pull/1454) -* Update AGENTS.md by [@jlowin](https://github.com/jlowin) in [#1471](https://github.com/PrefectHQ/fastmcp/pull/1471) -* Give Marvin the power of the Internet by [@strawgate](https://github.com/strawgate) in [#1475](https://github.com/PrefectHQ/fastmcp/pull/1475) -* Update `just` error message for static checks by [@jlowin](https://github.com/jlowin) in [#1483](https://github.com/PrefectHQ/fastmcp/pull/1483) -* Remove labeler by [@jlowin](https://github.com/jlowin) in [#1509](https://github.com/PrefectHQ/fastmcp/pull/1509) -* update aproto server to handle rich links by [@zzstoatzz](https://github.com/zzstoatzz) in [#1556](https://github.com/PrefectHQ/fastmcp/pull/1556) -* fix: enable triage bot for fork PRs using pull_request_target by [@jlowin](https://github.com/jlowin) in [#1557](https://github.com/PrefectHQ/fastmcp/pull/1557) - -## New Contributors -* [@thomas-te](https://github.com/thomas-te) made their first contribution in [#1496](https://github.com/PrefectHQ/fastmcp/pull/1496) -* [@maybenotconnor](https://github.com/maybenotconnor) made their first contribution in [#1511](https://github.com/PrefectHQ/fastmcp/pull/1511) -* [@MaikelVeen](https://github.com/MaikelVeen) made their first contribution in [#1499](https://github.com/PrefectHQ/fastmcp/pull/1499) -* [@KaliszS](https://github.com/KaliszS) made their first contribution in [#1538](https://github.com/PrefectHQ/fastmcp/pull/1538) -* [@isra17](https://github.com/isra17) made their first contribution in [#1575](https://github.com/PrefectHQ/fastmcp/pull/1575) -* [@marvin-context-protocol](https://github.com/marvin-context-protocol)[bot] made their first contribution in [#1616](https://github.com/PrefectHQ/fastmcp/pull/1616) -* [@pldesch-chift](https://github.com/pldesch-chift) made their first contribution in [#1578](https://github.com/PrefectHQ/fastmcp/pull/1578) -* [@vl-kp](https://github.com/vl-kp) made their first contribution in [#1636](https://github.com/PrefectHQ/fastmcp/pull/1636) -* [@romanusyk](https://github.com/romanusyk) made their first contribution in [#1667](https://github.com/PrefectHQ/fastmcp/pull/1667) - -**Full Changelog**: [v2.11.3...v2.12.0](https://github.com/PrefectHQ/fastmcp/compare/v2.11.3...v2.12.0) - -</Update> - -<Update label="v2.11.3" description="2025-08-11"> - -**[v2.11.3: API-tite for Change](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.3)** - -This release includes significant enhancements to the experimental OpenAPI parser and fixes a significant bug that led schemas not to be included in input/output schemas if they were transitive dependencies (e.g. A → B → C implies A depends on C). For users naively transforming large OpenAPI specs into MCP servers, this may result in ballooning payload sizes and necessitate curation. - -## What's Changed -### Enhancements 🔧 -* Improve redirect handling to address 307's by [@jlowin](https://github.com/jlowin) in [#1387](https://github.com/PrefectHQ/fastmcp/pull/1387) -* Ensure resource + template names are properly prefixed when importing/mounting by [@jlowin](https://github.com/jlowin) in [#1423](https://github.com/PrefectHQ/fastmcp/pull/1423) -* fixes #1398: Add JWT claims to AccessToken by [@panargirakis](https://github.com/panargirakis) in [#1399](https://github.com/PrefectHQ/fastmcp/pull/1399) -* Enable Protected Resource Metadata to provide resource_name and resou… by [@yannj-fr](https://github.com/yannj-fr) in [#1371](https://github.com/PrefectHQ/fastmcp/pull/1371) -* Pin mcp SDK under 2.0 to avoid breaking changes by [@jlowin](https://github.com/jlowin) in [#1428](https://github.com/PrefectHQ/fastmcp/pull/1428) -* Clean up complexity from PR #1426 by [@jlowin](https://github.com/jlowin) in [#1435](https://github.com/PrefectHQ/fastmcp/pull/1435) -* Optimize OpenAPI payload size by 46% by [@jlowin](https://github.com/jlowin) in [#1452](https://github.com/PrefectHQ/fastmcp/pull/1452) -* Update static checks by [@jlowin](https://github.com/jlowin) in [#1448](https://github.com/PrefectHQ/fastmcp/pull/1448) -### Fixes 🐞 -* Fix client-side logging bug #1394 by [@chi2liu](https://github.com/chi2liu) in [#1397](https://github.com/PrefectHQ/fastmcp/pull/1397) -* fix: Fix httpx_client_factory type annotation to match MCP SDK (#1402) by [@chi2liu](https://github.com/chi2liu) in [#1405](https://github.com/PrefectHQ/fastmcp/pull/1405) -* Fix OpenAPI allOf handling at requestBody top level (#1378) by [@chi2liu](https://github.com/chi2liu) in [#1425](https://github.com/PrefectHQ/fastmcp/pull/1425) -* Fix OpenAPI transitive references and performance (#1372) by [@jlowin](https://github.com/jlowin) in [#1426](https://github.com/PrefectHQ/fastmcp/pull/1426) -* fix(type): lifespan is partially unknown by [@ykun9](https://github.com/ykun9) in [#1389](https://github.com/PrefectHQ/fastmcp/pull/1389) -* Ensure transformed tools generate structured content by [@jlowin](https://github.com/jlowin) in [#1443](https://github.com/PrefectHQ/fastmcp/pull/1443) -### Docs 📚 -* docs(client/logging): reflect corrected default log level mapping by [@jlowin](https://github.com/jlowin) in [#1403](https://github.com/PrefectHQ/fastmcp/pull/1403) -* Add documentation for get_access_token() dependency function by [@jlowin](https://github.com/jlowin) in [#1446](https://github.com/PrefectHQ/fastmcp/pull/1446) -### Other Changes 🦾 -* Add comprehensive tests for utilities.components module by [@chi2liu](https://github.com/chi2liu) in [#1395](https://github.com/PrefectHQ/fastmcp/pull/1395) -* Consolidate agent instructions into AGENTS.md by [@jlowin](https://github.com/jlowin) in [#1404](https://github.com/PrefectHQ/fastmcp/pull/1404) -* Fix performance test threshold to prevent flaky failures by [@jlowin](https://github.com/jlowin) in [#1406](https://github.com/PrefectHQ/fastmcp/pull/1406) -* Update agents.md; add github instructions by [@jlowin](https://github.com/jlowin) in [#1410](https://github.com/PrefectHQ/fastmcp/pull/1410) -* Add Marvin assistant by [@jlowin](https://github.com/jlowin) in [#1412](https://github.com/PrefectHQ/fastmcp/pull/1412) -* Marvin: fix deprecated variable names by [@jlowin](https://github.com/jlowin) in [#1417](https://github.com/PrefectHQ/fastmcp/pull/1417) -* Simplify action setup and add github tools for Marvin by [@jlowin](https://github.com/jlowin) in [#1419](https://github.com/PrefectHQ/fastmcp/pull/1419) -* Update marvin workflow name by [@jlowin](https://github.com/jlowin) in [#1421](https://github.com/PrefectHQ/fastmcp/pull/1421) -* Improve GitHub templates by [@jlowin](https://github.com/jlowin) in [#1422](https://github.com/PrefectHQ/fastmcp/pull/1422) - -## New Contributors -* [@panargirakis](https://github.com/panargirakis) made their first contribution in [#1399](https://github.com/PrefectHQ/fastmcp/pull/1399) -* [@ykun9](https://github.com/ykun9) made their first contribution in [#1389](https://github.com/PrefectHQ/fastmcp/pull/1389) -* [@yannj-fr](https://github.com/yannj-fr) made their first contribution in [#1371](https://github.com/PrefectHQ/fastmcp/pull/1371) - -**Full Changelog**: [v2.11.2...v2.11.3](https://github.com/PrefectHQ/fastmcp/compare/v2.11.2...v2.11.3) - -</Update> - -<Update label="v2.11.2" description="2025-08-06"> - -## [v2.11.2: Satis-factory](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.2) - -## What's Changed -### Enhancements 🔧 -* Support factory functions in fastmcp run by [@jlowin](https://github.com/jlowin) in [#1384](https://github.com/PrefectHQ/fastmcp/pull/1384) -* Add async support to client_factory in FastMCPProxy (#1286) by [@bianning](https://github.com/bianning) in [#1375](https://github.com/PrefectHQ/fastmcp/pull/1375) -### Fixes 🐞 -* Fix server_version field in inspect manifest by [@jlowin](https://github.com/jlowin) in [#1383](https://github.com/PrefectHQ/fastmcp/pull/1383) -* Fix Settings field with both default and default_factory by [@jlowin](https://github.com/jlowin) in [#1380](https://github.com/PrefectHQ/fastmcp/pull/1380) -### Other Changes 🦾 -* Remove unused arg by [@jlowin](https://github.com/jlowin) in [#1382](https://github.com/PrefectHQ/fastmcp/pull/1382) -* Add remote auth provider tests by [@jlowin](https://github.com/jlowin) in [#1351](https://github.com/PrefectHQ/fastmcp/pull/1351) - -## New Contributors -* [@bianning](https://github.com/bianning) made their first contribution in [#1375](https://github.com/PrefectHQ/fastmcp/pull/1375) - -**Full Changelog**: [v2.11.1...v2.11.2](https://github.com/PrefectHQ/fastmcp/compare/v2.11.1...v2.11.2) - -</Update> - -<Update label="v2.11.1" description="2025-08-04"> - -## [v2.11.1: You're Better Auth Now](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.1) - -## What's Changed -### New Features 🎉 -* Introduce `RemoteAuthProvider` for cleaner external identity provider integration, update docs by [@jlowin](https://github.com/jlowin) in [#1346](https://github.com/PrefectHQ/fastmcp/pull/1346) -### Enhancements 🔧 -* perf: optimize string operations in OpenAPI parameter processing by [@chi2liu](https://github.com/chi2liu) in [#1342](https://github.com/PrefectHQ/fastmcp/pull/1342) -### Fixes 🐞 -* Fix method-bound FunctionTool schemas by [@strawgate](https://github.com/strawgate) in [#1360](https://github.com/PrefectHQ/fastmcp/pull/1360) -* Manually set `_key` after `model_copy()` to enable prefixing Transformed Tools by [@strawgate](https://github.com/strawgate) in [#1357](https://github.com/PrefectHQ/fastmcp/pull/1357) -### Docs 📚 -* Docs updates by [@jlowin](https://github.com/jlowin) in [#1336](https://github.com/PrefectHQ/fastmcp/pull/1336) -* Add 2.11 to changelog by [@jlowin](https://github.com/jlowin) in [#1337](https://github.com/PrefectHQ/fastmcp/pull/1337) -* Update AuthKit vocab by [@jlowin](https://github.com/jlowin) in [#1338](https://github.com/PrefectHQ/fastmcp/pull/1338) -* Fix typo in decorating-methods.mdx by [@Ozzuke](https://github.com/Ozzuke) in [#1344](https://github.com/PrefectHQ/fastmcp/pull/1344) - -## New Contributors -* [@Ozzuke](https://github.com/Ozzuke) made their first contribution in [#1344](https://github.com/PrefectHQ/fastmcp/pull/1344) - -**Full Changelog**: [v2.11.0...v2.11.1](https://github.com/PrefectHQ/fastmcp/compare/v2.11.0...v2.11.1) - -</Update> - -<Update label="v2.11.0" description="2025-08-01"> - -## [v2.11.0: Auth to a Good Start](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.0) - -FastMCP 2.11 doubles down on what developers need most: speed and simplicity. This massive release delivers significant performance improvements and a dramatically better developer experience. - -🔐 **Enterprise-Ready Authentication** brings comprehensive OAuth 2.1 support with WorkOS's AuthKit integration. The new AuthProvider interface leverages MCP's support for separate resource and authorization servers, handling API keys and remote authentication with Dynamic Client Registration. AuthKit integration means you can plug into existing enterprise identity systems without rebuilding your auth stack, setting the stage for plug-and-play auth that doesn't require users to become security experts overnight. - -⚡ The **Experimental OpenAPI Parser** delivers dramatic performance improvements through single-pass schema processing and optimized memory usage. OpenAPI integrations are now significantly faster, with cleaner, more maintainable code. _(Note: the experimental parser is disabled by default, set `FASTMCPEXPERIMENTALENABLENEWOPENAPIPARSER=1` to enable it. A message will be shown to all users on the legacy parser encouraging them to try the new one before it becomes the default.)_ - -🧠 **Context State Management** finally gives you persistent state across tool calls with a simple dict interface, while enhanced meta support lets you expose rich component metadata to clients. Combined with improved type annotations, string-based argument descriptions, and UV transport support, this release makes FastMCP feel more intuitive than ever. - -This release represents a TON of community contributions and sets the foundation for even more ambitious features ahead. - -## What's Changed -### New Features 🎉 -* Introduce experimental OpenAPI parser with improved performance and maintainability by [@jlowin](https://github.com/jlowin) in [#1209](https://github.com/PrefectHQ/fastmcp/pull/1209) -* Add state dict to Context (#1118) by [@mukulmurthy](https://github.com/mukulmurthy) in [#1160](https://github.com/PrefectHQ/fastmcp/pull/1160) -* Expose FastMCP tags to clients via component `meta` dict by [@jlowin](https://github.com/jlowin) in [#1281](https://github.com/PrefectHQ/fastmcp/pull/1281) -* Add _fastmcp meta namespace by [@jlowin](https://github.com/jlowin) in [#1290](https://github.com/PrefectHQ/fastmcp/pull/1290) -* Add TokenVerifier protocol support alongside existing OAuthProvider authentication by [@jlowin](https://github.com/jlowin) in [#1297](https://github.com/PrefectHQ/fastmcp/pull/1297) -* Add comprehensive OAuth 2.1 authentication system with WorkOS integration by [@jlowin](https://github.com/jlowin) in [#1327](https://github.com/PrefectHQ/fastmcp/pull/1327) -### Enhancements 🔧 -* [🐶] Transform MCP Server Tools by [@strawgate](https://github.com/strawgate) in [#1132](https://github.com/PrefectHQ/fastmcp/pull/1132) -* Add --python, --project, and --with-requirements options to CLI commands by [@jlowin](https://github.com/jlowin) in [#1190](https://github.com/PrefectHQ/fastmcp/pull/1190) -* Support `fastmcp run mcp.json` by [@strawgate](https://github.com/strawgate) in [#1138](https://github.com/PrefectHQ/fastmcp/pull/1138) -* Support from __future__ import annotations by [@jlowin](https://github.com/jlowin) in [#1199](https://github.com/PrefectHQ/fastmcp/pull/1199) -* Optimize OpenAPI parser performance with single-pass schema processing by [@jlowin](https://github.com/jlowin) in [#1214](https://github.com/PrefectHQ/fastmcp/pull/1214) -* Log tool name on transform validation error by [@strawgate](https://github.com/strawgate) in [#1238](https://github.com/PrefectHQ/fastmcp/pull/1238) -* Refactor `get_http_request` and `context.session_id` by [@hopeful0](https://github.com/hopeful0) in [#1242](https://github.com/PrefectHQ/fastmcp/pull/1242) -* Support creating tool argument descriptions from string annotations by [@jlowin](https://github.com/jlowin) in [#1255](https://github.com/PrefectHQ/fastmcp/pull/1255) -* feat: Add Annotations support for resources and resource templates by [@chughtapan](https://github.com/chughtapan) in [#1260](https://github.com/PrefectHQ/fastmcp/pull/1260) -* Add UV Transport by [@strawgate](https://github.com/strawgate) in [#1270](https://github.com/PrefectHQ/fastmcp/pull/1270) -* Improve OpenAPI-to-JSONSchema conversion utilities by [@jlowin](https://github.com/jlowin) in [#1283](https://github.com/PrefectHQ/fastmcp/pull/1283) -* Ensure proxy components forward meta dicts by [@jlowin](https://github.com/jlowin) in [#1282](https://github.com/PrefectHQ/fastmcp/pull/1282) -* fix: server argument passing in CLI run command by [@chughtapan](https://github.com/chughtapan) in [#1293](https://github.com/PrefectHQ/fastmcp/pull/1293) -* Add meta support to tool transformation utilities by [@jlowin](https://github.com/jlowin) in [#1295](https://github.com/PrefectHQ/fastmcp/pull/1295) -* feat: Allow Resource Metadata URL as field in OAuthProvider by [@dacamposol](https://github.com/dacamposol) in [#1287](https://github.com/PrefectHQ/fastmcp/pull/1287) -* Use a simple overwrite instead of a merge for meta by [@jlowin](https://github.com/jlowin) in [#1296](https://github.com/PrefectHQ/fastmcp/pull/1296) -* Remove unused TimedCache by [@strawgate](https://github.com/strawgate) in [#1303](https://github.com/PrefectHQ/fastmcp/pull/1303) -* refactor: standardize logging usage across OpenAPI utilities by [@chi2liu](https://github.com/chi2liu) in [#1322](https://github.com/PrefectHQ/fastmcp/pull/1322) -* perf: optimize OpenAPI parsing by reducing dict copy operations by [@chi2liu](https://github.com/chi2liu) in [#1321](https://github.com/PrefectHQ/fastmcp/pull/1321) -* Structured client-side logging by [@cjermain](https://github.com/cjermain) in [#1326](https://github.com/PrefectHQ/fastmcp/pull/1326) -### Fixes 🐞 -* fix: preserve def reference when referenced in allOf / oneOf / anyOf by [@algirdasci](https://github.com/algirdasci) in [#1208](https://github.com/PrefectHQ/fastmcp/pull/1208) -* fix: add type hint to custom_route decorator by [@zzstoatzz](https://github.com/zzstoatzz) in [#1210](https://github.com/PrefectHQ/fastmcp/pull/1210) -* chore: typo by [@richardkmichael](https://github.com/richardkmichael) in [#1216](https://github.com/PrefectHQ/fastmcp/pull/1216) -* fix: handle non-string $ref values in experimental OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#1217](https://github.com/PrefectHQ/fastmcp/pull/1217) -* Skip repeated type conversion and validation in proxy client elicitation handler by [@chughtapan](https://github.com/chughtapan) in [#1222](https://github.com/PrefectHQ/fastmcp/pull/1222) -* Ensure default fields are not marked nullable by [@jlowin](https://github.com/jlowin) in [#1224](https://github.com/PrefectHQ/fastmcp/pull/1224) -* Fix stateful proxy client mixing in multi-proxies sessions by [@hopeful0](https://github.com/hopeful0) in [#1245](https://github.com/PrefectHQ/fastmcp/pull/1245) -* Fix invalid async context manager usage in proxy documentation by [@zzstoatzz](https://github.com/zzstoatzz) in [#1246](https://github.com/PrefectHQ/fastmcp/pull/1246) -* fix: experimental FastMCPOpenAPI server lost headers in request when __init__(client with headers) by [@itaru2622](https://github.com/itaru2622) in [#1254](https://github.com/PrefectHQ/fastmcp/pull/1254) -* Fix typing, add tests for tool call middleware by [@jlowin](https://github.com/jlowin) in [#1269](https://github.com/PrefectHQ/fastmcp/pull/1269) -* Fix: prune hidden parameter defs by [@muhammadkhalid-03](https://github.com/muhammadkhalid-03) in [#1257](https://github.com/PrefectHQ/fastmcp/pull/1257) -* Fix nullable field handling in OpenAPI to JSON Schema conversion by [@jlowin](https://github.com/jlowin) in [#1279](https://github.com/PrefectHQ/fastmcp/pull/1279) -* Ensure fastmcp run supports v1 servers by [@jlowin](https://github.com/jlowin) in [#1332](https://github.com/PrefectHQ/fastmcp/pull/1332) -### Breaking Changes 🛫 -* Change server flag to --name by [@jlowin](https://github.com/jlowin) in [#1248](https://github.com/PrefectHQ/fastmcp/pull/1248) -### Docs 📚 -* Remove unused import from FastAPI integration documentation by [@mariotaddeucci](https://github.com/mariotaddeucci) in [#1194](https://github.com/PrefectHQ/fastmcp/pull/1194) -* Update fastapi docs by [@jlowin](https://github.com/jlowin) in [#1198](https://github.com/PrefectHQ/fastmcp/pull/1198) -* Add docs for context state management by [@jlowin](https://github.com/jlowin) in [#1227](https://github.com/PrefectHQ/fastmcp/pull/1227) -* Permit.io integration docs by [@orweis](https://github.com/orweis) in [#1226](https://github.com/PrefectHQ/fastmcp/pull/1226) -* Update docs to reflect sync tools by [@jlowin](https://github.com/jlowin) in [#1234](https://github.com/PrefectHQ/fastmcp/pull/1234) -* Update changelog.mdx by [@jlowin](https://github.com/jlowin) in [#1235](https://github.com/PrefectHQ/fastmcp/pull/1235) -* Update SDK docs by [@jlowin](https://github.com/jlowin) in [#1236](https://github.com/PrefectHQ/fastmcp/pull/1236) -* Update --name flag documentation for Cursor/Claude by [@adam-conway](https://github.com/adam-conway) in [#1239](https://github.com/PrefectHQ/fastmcp/pull/1239) -* Add annotations docs by [@jlowin](https://github.com/jlowin) in [#1268](https://github.com/PrefectHQ/fastmcp/pull/1268) -* Update openapi/fastapi URLs README.md by [@jbn](https://github.com/jbn) in [#1278](https://github.com/PrefectHQ/fastmcp/pull/1278) -* Add 2.11 version badge for state management by [@jlowin](https://github.com/jlowin) in [#1289](https://github.com/PrefectHQ/fastmcp/pull/1289) -* Add meta parameter support to tools, resources, templates, and prompts decorators by [@jlowin](https://github.com/jlowin) in [#1294](https://github.com/PrefectHQ/fastmcp/pull/1294) -* docs: update get_state and set_state references by [@Maxi91f](https://github.com/Maxi91f) in [#1306](https://github.com/PrefectHQ/fastmcp/pull/1306) -* Add unit tests and docs for denying tool calls with middleware by [@jlowin](https://github.com/jlowin) in [#1333](https://github.com/PrefectHQ/fastmcp/pull/1333) -* Remove reference to stacked decorators by [@jlowin](https://github.com/jlowin) in [#1334](https://github.com/PrefectHQ/fastmcp/pull/1334) -* Eunomia authorization server can run embedded within the MCP server by [@tommitt](https://github.com/tommitt) in [#1317](https://github.com/PrefectHQ/fastmcp/pull/1317) -### Other Changes 🦾 -* Update README.md by [@jlowin](https://github.com/jlowin) in [#1230](https://github.com/PrefectHQ/fastmcp/pull/1230) -* Logcapture addition to test_server file by [@Sourav-Tripathy](https://github.com/Sourav-Tripathy) in [#1229](https://github.com/PrefectHQ/fastmcp/pull/1229) -* Add tests for headers with both legacy and experimental openapi parser by [@jlowin](https://github.com/jlowin) in [#1259](https://github.com/PrefectHQ/fastmcp/pull/1259) -* Small clean-up from MCP Tool Transform PR by [@strawgate](https://github.com/strawgate) in [#1267](https://github.com/PrefectHQ/fastmcp/pull/1267) -* Add test for proxy tags visibility by [@jlowin](https://github.com/jlowin) in [#1302](https://github.com/PrefectHQ/fastmcp/pull/1302) -* Add unit test for sampling with image messages by [@jlowin](https://github.com/jlowin) in [#1329](https://github.com/PrefectHQ/fastmcp/pull/1329) -* Remove redundant resource_metadata_url assignment by [@jlowin](https://github.com/jlowin) in [#1328](https://github.com/PrefectHQ/fastmcp/pull/1328) -* Update bug.yml by [@jlowin](https://github.com/jlowin) in [#1331](https://github.com/PrefectHQ/fastmcp/pull/1331) -* Ensure validation errors are raised when masked by [@jlowin](https://github.com/jlowin) in [#1330](https://github.com/PrefectHQ/fastmcp/pull/1330) - -## New Contributors -* [@mariotaddeucci](https://github.com/mariotaddeucci) made their first contribution in [#1194](https://github.com/PrefectHQ/fastmcp/pull/1194) -* [@algirdasci](https://github.com/algirdasci) made their first contribution in [#1208](https://github.com/PrefectHQ/fastmcp/pull/1208) -* [@chughtapan](https://github.com/chughtapan) made their first contribution in [#1222](https://github.com/PrefectHQ/fastmcp/pull/1222) -* [@mukulmurthy](https://github.com/mukulmurthy) made their first contribution in [#1160](https://github.com/PrefectHQ/fastmcp/pull/1160) -* [@orweis](https://github.com/orweis) made their first contribution in [#1226](https://github.com/PrefectHQ/fastmcp/pull/1226) -* [@Sourav-Tripathy](https://github.com/Sourav-Tripathy) made their first contribution in [#1229](https://github.com/PrefectHQ/fastmcp/pull/1229) -* [@adam-conway](https://github.com/adam-conway) made their first contribution in [#1239](https://github.com/PrefectHQ/fastmcp/pull/1239) -* [@muhammadkhalid-03](https://github.com/muhammadkhalid-03) made their first contribution in [#1257](https://github.com/PrefectHQ/fastmcp/pull/1257) -* [@jbn](https://github.com/jbn) made their first contribution in [#1278](https://github.com/PrefectHQ/fastmcp/pull/1278) -* [@dacamposol](https://github.com/dacamposol) made their first contribution in [#1287](https://github.com/PrefectHQ/fastmcp/pull/1287) -* [@chi2liu](https://github.com/chi2liu) made their first contribution in [#1322](https://github.com/PrefectHQ/fastmcp/pull/1322) -* [@cjermain](https://github.com/cjermain) made their first contribution in [#1326](https://github.com/PrefectHQ/fastmcp/pull/1326) - -**Full Changelog**: [v2.10.6...v2.11.0](https://github.com/PrefectHQ/fastmcp/compare/v2.10.6...v2.11.0) - -</Update> - -<Update label="v2.10.6" description="2025-07-19"> - -## [v2.10.6: Hymn for the Weekend](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.6) - -A special Saturday release with many fixes. - -## What's Changed -### Enhancements 🔧 -* Resolve #1139 -- Implement include_context argument in Context.sample by [@codingjoe](https://github.com/codingjoe) in [#1141](https://github.com/PrefectHQ/fastmcp/pull/1141) -* feat(settings): add log level normalization by [@ka2048](https://github.com/ka2048) in [#1171](https://github.com/PrefectHQ/fastmcp/pull/1171) -* add server name to mounted server warnings by [@artificial-aidan](https://github.com/artificial-aidan) in [#1147](https://github.com/PrefectHQ/fastmcp/pull/1147) -* Add StatefulProxyClient by [@hopeful0](https://github.com/hopeful0) in [#1109](https://github.com/PrefectHQ/fastmcp/pull/1109) -### Fixes 🐞 -* Fix OpenAPI empty parameters by [@FabrizioSandri](https://github.com/FabrizioSandri) in [#1128](https://github.com/PrefectHQ/fastmcp/pull/1128) -* Fix title field preservation in tool transformations by [@jlowin](https://github.com/jlowin) in [#1131](https://github.com/PrefectHQ/fastmcp/pull/1131) -* Fix optional parameter validation in OpenAPI integration by [@jlowin](https://github.com/jlowin) in [#1135](https://github.com/PrefectHQ/fastmcp/pull/1135) -* Do not silently exclude the "context" key from JSON body by [@melkamar](https://github.com/melkamar) in [#1153](https://github.com/PrefectHQ/fastmcp/pull/1153) -* Fix tool output schema generation to respect Pydantic serialization aliases by [@zzstoatzz](https://github.com/zzstoatzz) in [#1148](https://github.com/PrefectHQ/fastmcp/pull/1148) -* fix: _replace_ref_with_defs; ensure ref_path is string by [@itaru2622](https://github.com/itaru2622) in [#1164](https://github.com/PrefectHQ/fastmcp/pull/1164) -* Fix nesting when making OpenAPI arrays and objects optional by [@melkamar](https://github.com/melkamar) in [#1178](https://github.com/PrefectHQ/fastmcp/pull/1178) -* Fix `mcp-json` output format to include server name by [@jlowin](https://github.com/jlowin) in [#1185](https://github.com/PrefectHQ/fastmcp/pull/1185) -* Only configure logging one time by [@jlowin](https://github.com/jlowin) in [#1187](https://github.com/PrefectHQ/fastmcp/pull/1187) -### Docs 📚 -* Update changelog.mdx by [@jlowin](https://github.com/jlowin) in [#1127](https://github.com/PrefectHQ/fastmcp/pull/1127) -* Eunomia Authorization with native FastMCP's Middleware by [@tommitt](https://github.com/tommitt) in [#1144](https://github.com/PrefectHQ/fastmcp/pull/1144) -* update api ref for new `mdxify` version by [@zzstoatzz](https://github.com/zzstoatzz) in [#1182](https://github.com/PrefectHQ/fastmcp/pull/1182) -### Other Changes 🦾 -* Expand empty parameter filtering and add comprehensive tests by [@jlowin](https://github.com/jlowin) in [#1129](https://github.com/PrefectHQ/fastmcp/pull/1129) -* Add no-commit-to-branch hook by [@zzstoatzz](https://github.com/zzstoatzz) in [#1149](https://github.com/PrefectHQ/fastmcp/pull/1149) -* Update README.md by [@jlowin](https://github.com/jlowin) in [#1165](https://github.com/PrefectHQ/fastmcp/pull/1165) -* skip on rate limit by [@zzstoatzz](https://github.com/zzstoatzz) in [#1183](https://github.com/PrefectHQ/fastmcp/pull/1183) -* Remove deprecated proxy creation by [@jlowin](https://github.com/jlowin) in [#1186](https://github.com/PrefectHQ/fastmcp/pull/1186) -* Separate integration tests from unit tests in CI by [@jlowin](https://github.com/jlowin) in [#1188](https://github.com/PrefectHQ/fastmcp/pull/1188) - -## New Contributors -* [@FabrizioSandri](https://github.com/FabrizioSandri) made their first contribution in [#1128](https://github.com/PrefectHQ/fastmcp/pull/1128) -* [@melkamar](https://github.com/melkamar) made their first contribution in [#1153](https://github.com/PrefectHQ/fastmcp/pull/1153) -* [@codingjoe](https://github.com/codingjoe) made their first contribution in [#1141](https://github.com/PrefectHQ/fastmcp/pull/1141) -* [@itaru2622](https://github.com/itaru2622) made their first contribution in [#1164](https://github.com/PrefectHQ/fastmcp/pull/1164) -* [@ka2048](https://github.com/ka2048) made their first contribution in [#1171](https://github.com/PrefectHQ/fastmcp/pull/1171) -* [@artificial-aidan](https://github.com/artificial-aidan) made their first contribution in [#1147](https://github.com/PrefectHQ/fastmcp/pull/1147) - -**Full Changelog**: [v2.10.5...v2.10.6](https://github.com/PrefectHQ/fastmcp/compare/v2.10.5...v2.10.6) - -</Update> - -<Update label="v2.10.5" description="2025-07-11"> - -## [v2.10.5: Middle Management](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.5) - -A maintenance release focused on OpenAPI refinements and middleware fixes, plus console improvements. - -## What's Changed -### Enhancements 🔧 -* Fix Claude Code CLI detection for npm global installations by [@jlowin](https://github.com/jlowin) in [#1106](https://github.com/PrefectHQ/fastmcp/pull/1106) -* Fix OpenAPI parameter name collisions with location suffixing by [@jlowin](https://github.com/jlowin) in [#1107](https://github.com/PrefectHQ/fastmcp/pull/1107) -* Add mirrored component support for proxy servers by [@jlowin](https://github.com/jlowin) in [#1105](https://github.com/PrefectHQ/fastmcp/pull/1105) -### Fixes 🐞 -* Fix OpenAPI deepObject style parameter encoding by [@jlowin](https://github.com/jlowin) in [#1122](https://github.com/PrefectHQ/fastmcp/pull/1122) -* xfail when github token is not set ('' or None) by [@jlowin](https://github.com/jlowin) in [#1123](https://github.com/PrefectHQ/fastmcp/pull/1123) -* fix: replace oneOf with anyOf in OpenAPI output schemas by [@MagnusS0](https://github.com/MagnusS0) in [#1119](https://github.com/PrefectHQ/fastmcp/pull/1119) -* Fix middleware list result types by [@jlowin](https://github.com/jlowin) in [#1125](https://github.com/PrefectHQ/fastmcp/pull/1125) -* Improve console width for logo by [@jlowin](https://github.com/jlowin) in [#1126](https://github.com/PrefectHQ/fastmcp/pull/1126) -### Docs 📚 -* Improve transport + integration docs by [@jlowin](https://github.com/jlowin) in [#1103](https://github.com/PrefectHQ/fastmcp/pull/1103) -* Update proxy.mdx by [@coldfire-x](https://github.com/coldfire-x) in [#1108](https://github.com/PrefectHQ/fastmcp/pull/1108) -### Other Changes 🦾 -* Update github remote server tests with secret by [@jlowin](https://github.com/jlowin) in [#1112](https://github.com/PrefectHQ/fastmcp/pull/1112) - -## New Contributors -* [@coldfire-x](https://github.com/coldfire-x) made their first contribution in [#1108](https://github.com/PrefectHQ/fastmcp/pull/1108) -* [@MagnusS0](https://github.com/MagnusS0) made their first contribution in [#1119](https://github.com/PrefectHQ/fastmcp/pull/1119) - -**Full Changelog**: [v2.10.4...v2.10.5](https://github.com/PrefectHQ/fastmcp/compare/v2.10.4...v2.10.5) - -</Update> - -<Update label="v2.10.4" description="2025-07-09"> - -## [v2.10.4: Transport-ation](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.4) - -A quick fix to ensure the CLI accepts "streamable-http" as a valid transport option. - -## What's Changed -### Fixes 🐞 -* Ensure the CLI accepts "streamable-http" as a valid transport by [@jlowin](https://github.com/jlowin) in [#1099](https://github.com/PrefectHQ/fastmcp/pull/1099) - -**Full Changelog**: [v2.10.3...v2.10.4](https://github.com/PrefectHQ/fastmcp/compare/v2.10.3...v2.10.4) - -</Update> - -<Update label="v2.10.3" description="2025-07-09"> - -## [v2.10.3: CLI Me a River](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.3) - -A major CLI overhaul featuring a complete refactor from typer to cyclopts, new IDE integrations, and comprehensive OpenAPI improvements. - -## What's Changed -### New Features 🎉 -* Refactor CLI from typer to cyclopts and add comprehensive tests by [@jlowin](https://github.com/jlowin) in [#1062](https://github.com/PrefectHQ/fastmcp/pull/1062) -* Add output schema support for OpenAPI tools by [@jlowin](https://github.com/jlowin) in [#1073](https://github.com/PrefectHQ/fastmcp/pull/1073) -### Enhancements 🔧 -* Add Cursor support via CLI integration by [@jlowin](https://github.com/jlowin) in [#1052](https://github.com/PrefectHQ/fastmcp/pull/1052) -* Add Claude Code install integration by [@jlowin](https://github.com/jlowin) in [#1053](https://github.com/PrefectHQ/fastmcp/pull/1053) -* Generate MCP JSON config output from CLI as new `fastmcp install` command by [@jlowin](https://github.com/jlowin) in [#1056](https://github.com/PrefectHQ/fastmcp/pull/1056) -* Use isawaitable instead of iscoroutine by [@jlowin](https://github.com/jlowin) in [#1059](https://github.com/PrefectHQ/fastmcp/pull/1059) -* feat: Add `--path` Option to CLI for HTTP/SSE Route by [@davidbk-legit](https://github.com/davidbk-legit) in [#1087](https://github.com/PrefectHQ/fastmcp/pull/1087) -* Fix concurrent proxy client operations with session isolation by [@jlowin](https://github.com/jlowin) in [#1083](https://github.com/PrefectHQ/fastmcp/pull/1083) -### Fixes 🐞 -* Refactor Client context management to avoid concurrency issue by [@hopeful0](https://github.com/hopeful0) in [#1054](https://github.com/PrefectHQ/fastmcp/pull/1054) -* Keep json schema $defs on transform by [@strawgate](https://github.com/strawgate) in [#1066](https://github.com/PrefectHQ/fastmcp/pull/1066) -* Ensure fastmcp version copy is plaintext by [@jlowin](https://github.com/jlowin) in [#1071](https://github.com/PrefectHQ/fastmcp/pull/1071) -* Fix single-element list unwrapping in tool content by [@jlowin](https://github.com/jlowin) in [#1074](https://github.com/PrefectHQ/fastmcp/pull/1074) -* Fix max recursion error when pruning OpenAPI definitions by [@dimitribarbot](https://github.com/dimitribarbot) in [#1092](https://github.com/PrefectHQ/fastmcp/pull/1092) -* Fix OpenAPI tool name registration when modified by mcp_component_fn by [@jlowin](https://github.com/jlowin) in [#1096](https://github.com/PrefectHQ/fastmcp/pull/1096) -### Docs 📚 -* Docs: add example of more concise way to use bearer auth by [@neilconway](https://github.com/neilconway) in [#1055](https://github.com/PrefectHQ/fastmcp/pull/1055) -* Update favicon by [@jlowin](https://github.com/jlowin) in [#1058](https://github.com/PrefectHQ/fastmcp/pull/1058) -* Update environment note by [@jlowin](https://github.com/jlowin) in [#1075](https://github.com/PrefectHQ/fastmcp/pull/1075) -* Add fastmcp version --copy documentation by [@jlowin](https://github.com/jlowin) in [#1076](https://github.com/PrefectHQ/fastmcp/pull/1076) -### Other Changes 🦾 -* Remove asserts and add documentation following #1054 by [@jlowin](https://github.com/jlowin) in [#1057](https://github.com/PrefectHQ/fastmcp/pull/1057) -* Add --copy flag for fastmcp version by [@jlowin](https://github.com/jlowin) in [#1063](https://github.com/PrefectHQ/fastmcp/pull/1063) -* Fix docstring format for fastmcp.client.Client by [@neilconway](https://github.com/neilconway) in [#1094](https://github.com/PrefectHQ/fastmcp/pull/1094) - -## New Contributors -* [@neilconway](https://github.com/neilconway) made their first contribution in [#1055](https://github.com/PrefectHQ/fastmcp/pull/1055) -* [@davidbk-legit](https://github.com/davidbk-legit) made their first contribution in [#1087](https://github.com/PrefectHQ/fastmcp/pull/1087) -* [@dimitribarbot](https://github.com/dimitribarbot) made their first contribution in [#1092](https://github.com/PrefectHQ/fastmcp/pull/1092) - -**Full Changelog**: [v2.10.2...v2.10.3](https://github.com/PrefectHQ/fastmcp/compare/v2.10.2...v2.10.3) - -</Update> - -<Update label="v2.10.2" description="2025-07-05"> - -## [v2.10.2: Forward March](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.2) - -The headline feature of this release is the ability to "forward" advanced MCP interactions like logging, progress, and elicitation through proxy servers. If the remote server requests an elicitation, the proxy client will pass that request to the new, "ultimate" client. - -## What's Changed -### New Features 🎉 -* Proxy support advanced MCP features by [@hopeful0](https://github.com/hopeful0) in [#1022](https://github.com/PrefectHQ/fastmcp/pull/1022) -### Enhancements 🔧 -* Re-add splash screen by [@jlowin](https://github.com/jlowin) in [#1027](https://github.com/PrefectHQ/fastmcp/pull/1027) -* Reduce banner padding by [@jlowin](https://github.com/jlowin) in [#1030](https://github.com/PrefectHQ/fastmcp/pull/1030) -* Allow per-server timeouts in MCPConfig by [@cegersdoerfer](https://github.com/cegersdoerfer) in [#1031](https://github.com/PrefectHQ/fastmcp/pull/1031) -* Support 'scp' claim for OAuth scopes in BearerAuthProvider by [@jlowin](https://github.com/jlowin) in [#1033](https://github.com/PrefectHQ/fastmcp/pull/1033) -* Add path expansion to image/audio/file by [@jlowin](https://github.com/jlowin) in [#1038](https://github.com/PrefectHQ/fastmcp/pull/1038) -* Ensure multi-client configurations use new ProxyClient by [@jlowin](https://github.com/jlowin) in [#1045](https://github.com/PrefectHQ/fastmcp/pull/1045) -### Fixes 🐞 -* Expose stateless_http kwarg for mcp.run() by [@jlowin](https://github.com/jlowin) in [#1018](https://github.com/PrefectHQ/fastmcp/pull/1018) -* Avoid propagating logs by [@jlowin](https://github.com/jlowin) in [#1042](https://github.com/PrefectHQ/fastmcp/pull/1042) -### Docs 📚 -* Clean up docs by [@jlowin](https://github.com/jlowin) in [#1028](https://github.com/PrefectHQ/fastmcp/pull/1028) -* Docs: clarify server URL paths for ChatGPT integration by [@thap2331](https://github.com/thap2331) in [#1017](https://github.com/PrefectHQ/fastmcp/pull/1017) -### Other Changes 🦾 -* Split giant openapi test file into smaller files by [@jlowin](https://github.com/jlowin) in [#1034](https://github.com/PrefectHQ/fastmcp/pull/1034) -* Add comprehensive OpenAPI 3.0 vs 3.1 compatibility tests by [@jlowin](https://github.com/jlowin) in [#1035](https://github.com/PrefectHQ/fastmcp/pull/1035) -* Update banner and use console.log by [@jlowin](https://github.com/jlowin) in [#1041](https://github.com/PrefectHQ/fastmcp/pull/1041) - -## New Contributors -* [@cegersdoerfer](https://github.com/cegersdoerfer) made their first contribution in [#1031](https://github.com/PrefectHQ/fastmcp/pull/1031) -* [@hopeful0](https://github.com/hopeful0) made their first contribution in [#1022](https://github.com/PrefectHQ/fastmcp/pull/1022) -* [@thap2331](https://github.com/thap2331) made their first contribution in [#1017](https://github.com/PrefectHQ/fastmcp/pull/1017) - -**Full Changelog**: [v2.10.1...v2.10.2](https://github.com/PrefectHQ/fastmcp/compare/v2.10.1...v2.10.2) - -</Update> - -<Update label="v2.10.1" description="2025-07-02"> - -## [v2.10.1: Revert to Sender](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.1) - -A quick patch to revert the CLI banner that was added in v2.10.0. - -## What's Changed -### Docs 📚 -* Update changelog.mdx by [@jlowin](https://github.com/jlowin) in [#1009](https://github.com/PrefectHQ/fastmcp/pull/1009) -* Revert "Add CLI banner" by [@jlowin](https://github.com/jlowin) in [#1011](https://github.com/PrefectHQ/fastmcp/pull/1011) - -**Full Changelog**: [v2.10.0...v2.10.1](https://github.com/PrefectHQ/fastmcp/compare/v2.10.0...v2.10.1) - -</Update> - -<Update label="v2.10.0" description="2024-07-01"> - -## [v2.10.0: Great Spec-tations](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.0) - -FastMCP 2.10 brings full compliance with the 6/18/2025 MCP spec update, introducing elicitation support for dynamic server-client communication and output schemas for structured tool responses. Please note that due to these changes, this release also includes a breaking change to the return signature of `client.call_tool()`. - -### Elicitation Support -Elicitation allows MCP servers to request additional information from clients during tool execution, enabling more interactive and dynamic server behavior. This opens up new possibilities for tools that need user input or confirmation during execution. - -### Output Schemas -Tools can now define structured output schemas, ensuring that responses conform to expected formats and making tool integration more predictable and type-safe. - -## What's Changed -### New Features 🎉 -* MCP 6/18/25: Add output schema to tools by [@jlowin](https://github.com/jlowin) in [#901](https://github.com/PrefectHQ/fastmcp/pull/901) -* MCP 6/18/25: Elicitation support by [@jlowin](https://github.com/jlowin) in [#889](https://github.com/PrefectHQ/fastmcp/pull/889) -### Enhancements 🔧 -* Update types + tests for SDK changes by [@jlowin](https://github.com/jlowin) in [#888](https://github.com/PrefectHQ/fastmcp/pull/888) -* MCP 6/18/25: Update auth primitives by [@jlowin](https://github.com/jlowin) in [#966](https://github.com/PrefectHQ/fastmcp/pull/966) -* Add OpenAPI extensions support to HTTPRoute by [@maddymanu](https://github.com/maddymanu) in [#977](https://github.com/PrefectHQ/fastmcp/pull/977) -* Add title field support to FastMCP components by [@jlowin](https://github.com/jlowin) in [#982](https://github.com/PrefectHQ/fastmcp/pull/982) -* Support implicit Elicitation acceptance by [@jlowin](https://github.com/jlowin) in [#983](https://github.com/PrefectHQ/fastmcp/pull/983) -* Support 'no response' elicitation requests by [@jlowin](https://github.com/jlowin) in [#992](https://github.com/PrefectHQ/fastmcp/pull/992) -* Add Support for Configurable Algorithms by [@sstene1](https://github.com/sstene1) in [#997](https://github.com/PrefectHQ/fastmcp/pull/997) -### Fixes 🐞 -* Improve stdio error handling to raise connection failures immediately by [@jlowin](https://github.com/jlowin) in [#984](https://github.com/PrefectHQ/fastmcp/pull/984) -* Fix type hints for FunctionResource:fn by [@CfirTsabari](https://github.com/CfirTsabari) in [#986](https://github.com/PrefectHQ/fastmcp/pull/986) -* Update link to OpenAI MCP example by [@mossbanay](https://github.com/mossbanay) in [#985](https://github.com/PrefectHQ/fastmcp/pull/985) -* Fix output schema generation edge case by [@jlowin](https://github.com/jlowin) in [#995](https://github.com/PrefectHQ/fastmcp/pull/995) -* Refactor array parameter formatting to reduce code duplication by [@jlowin](https://github.com/jlowin) in [#1007](https://github.com/PrefectHQ/fastmcp/pull/1007) -* Fix OpenAPI array parameter explode handling by [@jlowin](https://github.com/jlowin) in [#1008](https://github.com/PrefectHQ/fastmcp/pull/1008) -### Breaking Changes 🛫 -* MCP 6/18/25: Upgrade to mcp 1.10 by [@jlowin](https://github.com/jlowin) in [#887](https://github.com/PrefectHQ/fastmcp/pull/887) -### Docs 📚 -* Update middleware imports and documentation by [@jlowin](https://github.com/jlowin) in [#999](https://github.com/PrefectHQ/fastmcp/pull/999) -* Update OpenAI docs by [@jlowin](https://github.com/jlowin) in [#1001](https://github.com/PrefectHQ/fastmcp/pull/1001) -* Add CLI banner by [@jlowin](https://github.com/jlowin) in [#1005](https://github.com/PrefectHQ/fastmcp/pull/1005) -### Examples & Contrib 💡 -* Component Manager by [@gorocode](https://github.com/gorocode) in [#976](https://github.com/PrefectHQ/fastmcp/pull/976) -### Other Changes 🦾 -* Minor auth improvements by [@jlowin](https://github.com/jlowin) in [#967](https://github.com/PrefectHQ/fastmcp/pull/967) -* Add .ccignore for copychat by [@jlowin](https://github.com/jlowin) in [#1000](https://github.com/PrefectHQ/fastmcp/pull/1000) - -## New Contributors -* [@maddymanu](https://github.com/maddymanu) made their first contribution in [#977](https://github.com/PrefectHQ/fastmcp/pull/977) -* [@github0hello](https://github.com/github0hello) made their first contribution in [#979](https://github.com/PrefectHQ/fastmcp/pull/979) -* [@tommitt](https://github.com/tommitt) made their first contribution in [#975](https://github.com/PrefectHQ/fastmcp/pull/975) -* [@CfirTsabari](https://github.com/CfirTsabari) made their first contribution in [#986](https://github.com/PrefectHQ/fastmcp/pull/986) -* [@mossbanay](https://github.com/mossbanay) made their first contribution in [#985](https://github.com/PrefectHQ/fastmcp/pull/985) -* [@sstene1](https://github.com/sstene1) made their first contribution in [#997](https://github.com/PrefectHQ/fastmcp/pull/997) - -**Full Changelog**: [v2.9.2...v2.10.0](https://github.com/PrefectHQ/fastmcp/compare/v2.9.2...v2.10.0) - -</Update> - -<Update label="v2.9.2" description="2024-06-26"> - -## [v2.9.2: Safety Pin](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.9.2) - -This is a patch release to pin `mcp` below 1.10, which includes changes related to the 6/18/2025 MCP spec update and could potentially break functionality for some FastMCP users. - -## What's Changed -### Docs 📚 -* Fix version badge for messages by [@jlowin](https://github.com/jlowin) in [#960](https://github.com/PrefectHQ/fastmcp/pull/960) -### Dependencies 📦 -* Pin mcp dependency by [@jlowin](https://github.com/jlowin) in [#962](https://github.com/PrefectHQ/fastmcp/pull/962) - -**Full Changelog**: [v2.9.1...v2.9.2](https://github.com/PrefectHQ/fastmcp/compare/v2.9.1...v2.9.2) - -</Update> - -<Update label="v2.9.1" description="2024-06-26"> - -## [v2.9.1: Call Me Maybe](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.9.1) - -FastMCP 2.9.1 introduces automatic MCP list change notifications, allowing servers to notify clients when tools, resources, or prompts are dynamically updated. This enables more responsive and adaptive MCP integrations. - -## What's Changed -### New Features 🎉 -* Add automatic MCP list change notifications and client message handling by [@jlowin](https://github.com/jlowin) in [#939](https://github.com/PrefectHQ/fastmcp/pull/939) -### Enhancements 🔧 -* Add debug logging to bearer token authentication by [@jlowin](https://github.com/jlowin) in [#952](https://github.com/PrefectHQ/fastmcp/pull/952) -### Fixes 🐞 -* Fix duplicate error logging in exception handlers by [@jlowin](https://github.com/jlowin) in [#938](https://github.com/PrefectHQ/fastmcp/pull/938) -* Fix parameter location enum handling in OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#953](https://github.com/PrefectHQ/fastmcp/pull/953) -* Fix external schema reference handling in OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#954](https://github.com/PrefectHQ/fastmcp/pull/954) -### Docs 📚 -* Update changelog for 2.9 release by [@jlowin](https://github.com/jlowin) in [#929](https://github.com/PrefectHQ/fastmcp/pull/929) -* Regenerate API references by [@zzstoatzz](https://github.com/zzstoatzz) in [#935](https://github.com/PrefectHQ/fastmcp/pull/935) -* Regenerate API references by [@zzstoatzz](https://github.com/zzstoatzz) in [#947](https://github.com/PrefectHQ/fastmcp/pull/947) -* Regenerate API references by [@zzstoatzz](https://github.com/zzstoatzz) in [#949](https://github.com/PrefectHQ/fastmcp/pull/949) -### Examples & Contrib 💡 -* Add `create_thread` tool to bsky MCP server by [@zzstoatzz](https://github.com/zzstoatzz) in [#927](https://github.com/PrefectHQ/fastmcp/pull/927) -* Update `mount_example.py` to work with current fastmcp API by [@rajephon](https://github.com/rajephon) in [#957](https://github.com/PrefectHQ/fastmcp/pull/957) - -## New Contributors -* [@rajephon](https://github.com/rajephon) made their first contribution in [#957](https://github.com/PrefectHQ/fastmcp/pull/957) - -**Full Changelog**: [v2.9.0...v2.9.1](https://github.com/PrefectHQ/fastmcp/compare/v2.9.0...v2.9.1) - -</Update> - -<Update label="v2.9.0" description="2024-06-23"> - -## [v2.9.0: Stuck in the Middleware With You](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.9.0) - -FastMCP 2.9 introduces two important features that push beyond the basic MCP protocol: MCP Middleware and server-side type conversion. - -### MCP Middleware -MCP middleware lets you intercept and modify requests and responses at the protocol level, giving you powerful capabilities for logging, authentication, validation, and more. This is particularly useful for building production-ready MCP servers that need sophisticated request handling. - -### Server-side Type Conversion -This release also introduces server-side type conversion for prompt arguments, ensuring that data is properly formatted before being passed to your functions. This reduces the burden on individual tools and prompts to handle type validation and conversion. - -## What's Changed -### New Features 🎉 -* Add File utility for binary data by [@gorocode](https://github.com/gorocode) in [#843](https://github.com/PrefectHQ/fastmcp/pull/843) -* Consolidate prefix logic into FastMCP methods by [@jlowin](https://github.com/jlowin) in [#861](https://github.com/PrefectHQ/fastmcp/pull/861) -* Add MCP Middleware by [@jlowin](https://github.com/jlowin) in [#870](https://github.com/PrefectHQ/fastmcp/pull/870) -* Implement server-side type conversion for prompt arguments by [@jlowin](https://github.com/jlowin) in [#908](https://github.com/PrefectHQ/fastmcp/pull/908) -### Enhancements 🔧 -* Fix tool description indentation issue by [@zfflxx](https://github.com/zfflxx) in [#845](https://github.com/PrefectHQ/fastmcp/pull/845) -* Add version parameter to FastMCP constructor by [@mkyutani](https://github.com/mkyutani) in [#842](https://github.com/PrefectHQ/fastmcp/pull/842) -* Update version to not be positional by [@jlowin](https://github.com/jlowin) in [#848](https://github.com/PrefectHQ/fastmcp/pull/848) -* Add key to component by [@jlowin](https://github.com/jlowin) in [#869](https://github.com/PrefectHQ/fastmcp/pull/869) -* Add session_id property to Context for data sharing by [@jlowin](https://github.com/jlowin) in [#881](https://github.com/PrefectHQ/fastmcp/pull/881) -* Fix CORS documentation example by [@jlowin](https://github.com/jlowin) in [#895](https://github.com/PrefectHQ/fastmcp/pull/895) -### Fixes 🐞 -* "report_progress missing passing related_request_id causes notifications not working" by [@alexsee](https://github.com/alexsee) in [#838](https://github.com/PrefectHQ/fastmcp/pull/838) -* Fix JWT issuer validation to support string values per RFC 7519 by [@jlowin](https://github.com/jlowin) in [#892](https://github.com/PrefectHQ/fastmcp/pull/892) -* Fix BearerAuthProvider audience type annotations by [@jlowin](https://github.com/jlowin) in [#894](https://github.com/PrefectHQ/fastmcp/pull/894) -### Docs 📚 -* Add CLAUDE.md development guidelines by [@jlowin](https://github.com/jlowin) in [#880](https://github.com/PrefectHQ/fastmcp/pull/880) -* Update context docs for session_id property by [@jlowin](https://github.com/jlowin) in [#882](https://github.com/PrefectHQ/fastmcp/pull/882) -* Add API reference by [@zzstoatzz](https://github.com/zzstoatzz) in [#893](https://github.com/PrefectHQ/fastmcp/pull/893) -* Fix API ref rendering by [@zzstoatzz](https://github.com/zzstoatzz) in [#900](https://github.com/PrefectHQ/fastmcp/pull/900) -* Simplify docs nav by [@jlowin](https://github.com/jlowin) in [#902](https://github.com/PrefectHQ/fastmcp/pull/902) -* Add fastmcp inspect command by [@jlowin](https://github.com/jlowin) in [#904](https://github.com/PrefectHQ/fastmcp/pull/904) -* Update client docs by [@jlowin](https://github.com/jlowin) in [#912](https://github.com/PrefectHQ/fastmcp/pull/912) -* Update docs nav by [@jlowin](https://github.com/jlowin) in [#913](https://github.com/PrefectHQ/fastmcp/pull/913) -* Update integration documentation for Claude Desktop, ChatGPT, and Claude Code by [@jlowin](https://github.com/jlowin) in [#915](https://github.com/PrefectHQ/fastmcp/pull/915) -* Add http as an alias for streamable http by [@jlowin](https://github.com/jlowin) in [#917](https://github.com/PrefectHQ/fastmcp/pull/917) -* Clean up parameter documentation by [@jlowin](https://github.com/jlowin) in [#918](https://github.com/PrefectHQ/fastmcp/pull/918) -* Add middleware examples for timing, logging, rate limiting, and error handling by [@jlowin](https://github.com/jlowin) in [#919](https://github.com/PrefectHQ/fastmcp/pull/919) -* ControlFlow → FastMCP rename by [@jlowin](https://github.com/jlowin) in [#922](https://github.com/PrefectHQ/fastmcp/pull/922) -### Examples & Contrib 💡 -* Add contrib.mcp_mixin support for annotations by [@rsp2k](https://github.com/rsp2k) in [#860](https://github.com/PrefectHQ/fastmcp/pull/860) -* Add ATProto (Bluesky) MCP Server Example by [@zzstoatzz](https://github.com/zzstoatzz) in [#916](https://github.com/PrefectHQ/fastmcp/pull/916) -* Fix path in atproto example pyproject by [@zzstoatzz](https://github.com/zzstoatzz) in [#920](https://github.com/PrefectHQ/fastmcp/pull/920) -* Remove uv source in example by [@zzstoatzz](https://github.com/zzstoatzz) in [#921](https://github.com/PrefectHQ/fastmcp/pull/921) - -## New Contributors -* [@alexsee](https://github.com/alexsee) made their first contribution in [#838](https://github.com/PrefectHQ/fastmcp/pull/838) -* [@zfflxx](https://github.com/zfflxx) made their first contribution in [#845](https://github.com/PrefectHQ/fastmcp/pull/845) -* [@mkyutani](https://github.com/mkyutani) made their first contribution in [#842](https://github.com/PrefectHQ/fastmcp/pull/842) -* [@gorocode](https://github.com/gorocode) made their first contribution in [#843](https://github.com/PrefectHQ/fastmcp/pull/843) -* [@rsp2k](https://github.com/rsp2k) made their first contribution in [#860](https://github.com/PrefectHQ/fastmcp/pull/860) -* [@owtaylor](https://github.com/owtaylor) made their first contribution in [#897](https://github.com/PrefectHQ/fastmcp/pull/897) -* [@Jason-CKY](https://github.com/Jason-CKY) made their first contribution in [#906](https://github.com/PrefectHQ/fastmcp/pull/906) - -**Full Changelog**: [v2.8.1...v2.9.0](https://github.com/PrefectHQ/fastmcp/compare/v2.8.1...v2.9.0) - -</Update> - -<Update label="v2.8.1" description="2024-06-15"> - -## [v2.8.1: Sound Judgement](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.8.1) - -2.8.1 introduces audio support, as well as minor fixes and updates for deprecated features. - -### Audio Support -This release adds support for audio content in MCP tools and resources, expanding FastMCP's multimedia capabilities beyond text and images. - -## What's Changed -### New Features 🎉 -* Add audio support by [@jlowin](https://github.com/jlowin) in [#833](https://github.com/PrefectHQ/fastmcp/pull/833) -### Enhancements 🔧 -* Add flag for disabling deprecation warnings by [@jlowin](https://github.com/jlowin) in [#802](https://github.com/PrefectHQ/fastmcp/pull/802) -* Add examples to Tool Arg Param transformation by [@strawgate](https://github.com/strawgate) in [#806](https://github.com/PrefectHQ/fastmcp/pull/806) -### Fixes 🐞 -* Restore .settings access as deprecated by [@jlowin](https://github.com/jlowin) in [#800](https://github.com/PrefectHQ/fastmcp/pull/800) -* Ensure handling of false http kwargs correctly; removed unused kwarg by [@jlowin](https://github.com/jlowin) in [#804](https://github.com/PrefectHQ/fastmcp/pull/804) -* Bump mcp 1.9.4 by [@jlowin](https://github.com/jlowin) in [#835](https://github.com/PrefectHQ/fastmcp/pull/835) -### Docs 📚 -* Update changelog for 2.8.0 by [@jlowin](https://github.com/jlowin) in [#794](https://github.com/PrefectHQ/fastmcp/pull/794) -* Update welcome docs by [@jlowin](https://github.com/jlowin) in [#808](https://github.com/PrefectHQ/fastmcp/pull/808) -* Update headers in docs by [@jlowin](https://github.com/jlowin) in [#809](https://github.com/PrefectHQ/fastmcp/pull/809) -* Add MCP group to tutorials by [@jlowin](https://github.com/jlowin) in [#810](https://github.com/PrefectHQ/fastmcp/pull/810) -* Add Community section to documentation by [@zzstoatzz](https://github.com/zzstoatzz) in [#819](https://github.com/PrefectHQ/fastmcp/pull/819) -* Add 2.8 update by [@jlowin](https://github.com/jlowin) in [#821](https://github.com/PrefectHQ/fastmcp/pull/821) -* Embed YouTube videos in community showcase by [@zzstoatzz](https://github.com/zzstoatzz) in [#820](https://github.com/PrefectHQ/fastmcp/pull/820) -### Other Changes 🦾 -* Ensure http args are passed through by [@jlowin](https://github.com/jlowin) in [#803](https://github.com/PrefectHQ/fastmcp/pull/803) -* Fix install link in readme by [@jlowin](https://github.com/jlowin) in [#836](https://github.com/PrefectHQ/fastmcp/pull/836) - -**Full Changelog**: [v2.8.0...v2.8.1](https://github.com/PrefectHQ/fastmcp/compare/v2.8.0...v2.8.1) - -</Update> - -<Update label="v2.8.0" description="2024-06-10"> - -## [v2.8.0: Transform and Roll Out](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.8.0) - -FastMCP 2.8.0 introduces powerful new ways to customize and control your MCP servers! - -### Tool Transformation - -The highlight of this release is first-class [**Tool Transformation**](/servers/transforms/tool-transformation), a new feature that lets you create enhanced variations of existing tools. You can now easily rename arguments, hide parameters, modify descriptions, and even wrap tools with custom validation or post-processing logic—all without rewriting the original code. This makes it easier than ever to adapt generic tools for specific LLM use cases or to simplify complex APIs. Huge thanks to [@strawgate](https://github.com/strawgate) for partnering on this, starting with [#591](https://github.com/PrefectHQ/fastmcp/discussions/591) and [#599](https://github.com/PrefectHQ/fastmcp/pull/599) and continuing offline. - -### Component Control -This release also gives you more granular control over which components are exposed to clients. With new [**tag-based filtering**](/servers/server#tag-based-filtering), you can selectively enable or disable tools, resources, and prompts based on tags, perfect for managing different environments or user permissions. Complementing this, every component now supports being [programmatically enabled or disabled](/servers/tools#disabling-tools), offering dynamic control over your server's capabilities. - -### Tools-by-Default -Finally, to improve compatibility with a wider range of LLM clients, this release changes the default behavior for OpenAPI integration: all API endpoints are now converted to `Tools` by default. This is a **breaking change** but pragmatically necessitated by the fact that the majority of MCP clients available today are, sadly, only compatible with MCP tools. Therefore, this change significantly simplifies the out-of-the-box experience and ensures your entire API is immediately accessible to any tool-using agent. - -## What's Changed -### New Features 🎉 -* First-class tool transformation by [@jlowin](https://github.com/jlowin) in [#745](https://github.com/PrefectHQ/fastmcp/pull/745) -* Support enable/disable for all FastMCP components (tools, prompts, resources, templates) by [@jlowin](https://github.com/jlowin) in [#781](https://github.com/PrefectHQ/fastmcp/pull/781) -* Add support for tag-based component filtering by [@jlowin](https://github.com/jlowin) in [#748](https://github.com/PrefectHQ/fastmcp/pull/748) -* Allow tag assignments for OpenAPI by [@jlowin](https://github.com/jlowin) in [#791](https://github.com/PrefectHQ/fastmcp/pull/791) -### Enhancements 🔧 -* Create common base class for components by [@jlowin](https://github.com/jlowin) in [#776](https://github.com/PrefectHQ/fastmcp/pull/776) -* Move components to own file; add resource by [@jlowin](https://github.com/jlowin) in [#777](https://github.com/PrefectHQ/fastmcp/pull/777) -* Update FastMCP component with __eq__ and __repr__ by [@jlowin](https://github.com/jlowin) in [#779](https://github.com/PrefectHQ/fastmcp/pull/779) -* Remove open-ended and server-specific settings by [@jlowin](https://github.com/jlowin) in [#750](https://github.com/PrefectHQ/fastmcp/pull/750) -### Fixes 🐞 -* Ensure client is only initialized once by [@jlowin](https://github.com/jlowin) in [#758](https://github.com/PrefectHQ/fastmcp/pull/758) -* Fix field validator for resource by [@jlowin](https://github.com/jlowin) in [#778](https://github.com/PrefectHQ/fastmcp/pull/778) -* Ensure proxies can overwrite remote tools without falling back to the remote by [@jlowin](https://github.com/jlowin) in [#782](https://github.com/PrefectHQ/fastmcp/pull/782) -### Breaking Changes 🛫 -* Treat all openapi routes as tools by [@jlowin](https://github.com/jlowin) in [#788](https://github.com/PrefectHQ/fastmcp/pull/788) -* Fix issue with global OpenAPI tags by [@jlowin](https://github.com/jlowin) in [#792](https://github.com/PrefectHQ/fastmcp/pull/792) -### Docs 📚 -* Minor docs updates by [@jlowin](https://github.com/jlowin) in [#755](https://github.com/PrefectHQ/fastmcp/pull/755) -* Add 2.7 update by [@jlowin](https://github.com/jlowin) in [#756](https://github.com/PrefectHQ/fastmcp/pull/756) -* Reduce 2.7 image size by [@jlowin](https://github.com/jlowin) in [#757](https://github.com/PrefectHQ/fastmcp/pull/757) -* Update updates.mdx by [@jlowin](https://github.com/jlowin) in [#765](https://github.com/PrefectHQ/fastmcp/pull/765) -* Hide docs sidebar scrollbar by default by [@jlowin](https://github.com/jlowin) in [#766](https://github.com/PrefectHQ/fastmcp/pull/766) -* Add "stop vibe testing" to tutorials by [@jlowin](https://github.com/jlowin) in [#767](https://github.com/PrefectHQ/fastmcp/pull/767) -* Add docs links by [@jlowin](https://github.com/jlowin) in [#768](https://github.com/PrefectHQ/fastmcp/pull/768) -* Fix: updated variable name under Gemini remote client by [@yrangana](https://github.com/yrangana) in [#769](https://github.com/PrefectHQ/fastmcp/pull/769) -* Revert "Hide docs sidebar scrollbar by default" by [@jlowin](https://github.com/jlowin) in [#770](https://github.com/PrefectHQ/fastmcp/pull/770) -* Add updates by [@jlowin](https://github.com/jlowin) in [#773](https://github.com/PrefectHQ/fastmcp/pull/773) -* Add tutorials by [@jlowin](https://github.com/jlowin) in [#783](https://github.com/PrefectHQ/fastmcp/pull/783) -* Update LLM-friendly docs by [@jlowin](https://github.com/jlowin) in [#784](https://github.com/PrefectHQ/fastmcp/pull/784) -* Update oauth.mdx by [@JeremyCraigMartinez](https://github.com/JeremyCraigMartinez) in [#787](https://github.com/PrefectHQ/fastmcp/pull/787) -* Add changelog by [@jlowin](https://github.com/jlowin) in [#789](https://github.com/PrefectHQ/fastmcp/pull/789) -* Add tutorials by [@jlowin](https://github.com/jlowin) in [#790](https://github.com/PrefectHQ/fastmcp/pull/790) -* Add docs for tag-based filtering by [@jlowin](https://github.com/jlowin) in [#793](https://github.com/PrefectHQ/fastmcp/pull/793) -### Other Changes 🦾 -* Create dependabot.yml by [@jlowin](https://github.com/jlowin) in [#759](https://github.com/PrefectHQ/fastmcp/pull/759) -* Bump astral-sh/setup-uv from 3 to 6 by [@dependabot](https://github.com/dependabot) in [#760](https://github.com/PrefectHQ/fastmcp/pull/760) -* Add dependencies section to release by [@jlowin](https://github.com/jlowin) in [#761](https://github.com/PrefectHQ/fastmcp/pull/761) -* Remove extra imports for MCPConfig by [@Maanas-Verma](https://github.com/Maanas-Verma) in [#763](https://github.com/PrefectHQ/fastmcp/pull/763) -* Split out enhancements in release notes by [@jlowin](https://github.com/jlowin) in [#764](https://github.com/PrefectHQ/fastmcp/pull/764) - -## New Contributors -* [@dependabot](https://github.com/dependabot) made their first contribution in [#760](https://github.com/PrefectHQ/fastmcp/pull/760) -* [@Maanas-Verma](https://github.com/Maanas-Verma) made their first contribution in [#763](https://github.com/PrefectHQ/fastmcp/pull/763) -* [@JeremyCraigMartinez](https://github.com/JeremyCraigMartinez) made their first contribution in [#787](https://github.com/PrefectHQ/fastmcp/pull/787) - -**Full Changelog**: [v2.7.1...v2.8.0](https://github.com/PrefectHQ/fastmcp/compare/v2.7.1...v2.8.0) - -</Update> - -<Update label="v2.7.1" description="2024-06-08"> - -## [v2.7.1: The Bearer Necessities](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.7.1) - -This release primarily contains a fix for parsing string tokens that are provided to FastMCP clients. - -### New Features 🎉 - -* Respect cache setting, set default to 1 second by [@jlowin](https://github.com/jlowin) in [#747](https://github.com/PrefectHQ/fastmcp/pull/747) - -### Fixes 🐞 - -* Ensure event store is properly typed by [@jlowin](https://github.com/jlowin) in [#753](https://github.com/PrefectHQ/fastmcp/pull/753) -* Fix passing token string to client auth & add auth to MCPConfig clients by [@jlowin](https://github.com/jlowin) in [#754](https://github.com/PrefectHQ/fastmcp/pull/754) - -### Docs 📚 - -* Docs : fix client to mcp\_client in Gemini example by [@yrangana](https://github.com/yrangana) in [#734](https://github.com/PrefectHQ/fastmcp/pull/734) -* update add tool docstring by [@strawgate](https://github.com/strawgate) in [#739](https://github.com/PrefectHQ/fastmcp/pull/739) -* Fix contrib link by [@richardkmichael](https://github.com/richardkmichael) in [#749](https://github.com/PrefectHQ/fastmcp/pull/749) - -### Other Changes 🦾 - -* Switch Pydantic defaults to kwargs by [@strawgate](https://github.com/strawgate) in [#731](https://github.com/PrefectHQ/fastmcp/pull/731) -* Fix Typo in CLI module by [@wfclark5](https://github.com/wfclark5) in [#737](https://github.com/PrefectHQ/fastmcp/pull/737) -* chore: fix prompt docstring by [@danb27](https://github.com/danb27) in [#752](https://github.com/PrefectHQ/fastmcp/pull/752) -* Add accept to excluded headers by [@jlowin](https://github.com/jlowin) in [#751](https://github.com/PrefectHQ/fastmcp/pull/751) - -### New Contributors - -* [@wfclark5](https://github.com/wfclark5) made their first contribution in [#737](https://github.com/PrefectHQ/fastmcp/pull/737) -* [@richardkmichael](https://github.com/richardkmichael) made their first contribution in [#749](https://github.com/PrefectHQ/fastmcp/pull/749) -* [@danb27](https://github.com/danb27) made their first contribution in [#752](https://github.com/PrefectHQ/fastmcp/pull/752) - -**Full Changelog**: [v2.7.0...v2.7.1](https://github.com/PrefectHQ/fastmcp/compare/v2.7.0...v2.7.1) -</Update> - -<Update label="v2.7.0" description="2024-06-05"> - -## [v2.7.0: Pare Programming](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.7.0) - -This is primarily a housekeeping release to remove or deprecate cruft that's accumulated since v1. Primarily, this release refactors FastMCP's internals in preparation for features planned in the next few major releases. However please note that as a result, this release has some minor breaking changes (which is why it's 2.7, not 2.6.2, in accordance with repo guidelines) though not to the core user-facing APIs. - -### Breaking Changes 🛫 - -* decorators return the objects they create, not the decorated function -* websockets is an optional dependency -* methods on the server for automatically converting functions into tools/resources/prompts have been deprecated in favor of using the decorators directly - -### New Features 🎉 - -* allow passing flags to servers by [@zzstoatzz](https://github.com/zzstoatzz) in [#690](https://github.com/PrefectHQ/fastmcp/pull/690) -* replace $ref pointing to `#/components/schemas/` with `#/$defs/` by [@phateffect](https://github.com/phateffect) in [#697](https://github.com/PrefectHQ/fastmcp/pull/697) -* Split Tool into Tool and FunctionTool by [@jlowin](https://github.com/jlowin) in [#700](https://github.com/PrefectHQ/fastmcp/pull/700) -* Use strict basemodel for Prompt; relax from\_function deprecation by [@jlowin](https://github.com/jlowin) in [#701](https://github.com/PrefectHQ/fastmcp/pull/701) -* Formalize resource/functionresource replationship by [@jlowin](https://github.com/jlowin) in [#702](https://github.com/PrefectHQ/fastmcp/pull/702) -* Formalize template/functiontemplate split by [@jlowin](https://github.com/jlowin) in [#703](https://github.com/PrefectHQ/fastmcp/pull/703) -* Support flexible @tool decorator call patterns by [@jlowin](https://github.com/jlowin) in [#706](https://github.com/PrefectHQ/fastmcp/pull/706) -* Ensure deprecation warnings have stacklevel=2 by [@jlowin](https://github.com/jlowin) in [#710](https://github.com/PrefectHQ/fastmcp/pull/710) -* Allow naked prompt decorator by [@jlowin](https://github.com/jlowin) in [#711](https://github.com/PrefectHQ/fastmcp/pull/711) - -### Fixes 🐞 - -* Updates / Fixes for Tool Content Conversion by [@strawgate](https://github.com/strawgate) in [#642](https://github.com/PrefectHQ/fastmcp/pull/642) -* Fix pr labeler permissions by [@jlowin](https://github.com/jlowin) in [#708](https://github.com/PrefectHQ/fastmcp/pull/708) -* remove -n auto by [@jlowin](https://github.com/jlowin) in [#709](https://github.com/PrefectHQ/fastmcp/pull/709) -* Fix links in README.md by [@alainivars](https://github.com/alainivars) in [#723](https://github.com/PrefectHQ/fastmcp/pull/723) - -Happily, this release DOES permit the use of "naked" decorators to align with Pythonic practice: - -```python -@mcp.tool -def my_tool(): - ... -``` - -**Full Changelog**: [v2.6.2...v2.7.0](https://github.com/PrefectHQ/fastmcp/compare/v2.6.2...v2.7.0) -</Update> - -<Update label="v2.6.1" description="2024-06-03"> - -## [v2.6.1: Blast Auth (second ignition)](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.6.1) - -This is a patch release to restore py.typed in #686. - -### Docs 📚 - -* Update readme by [@jlowin](https://github.com/jlowin) in [#679](https://github.com/PrefectHQ/fastmcp/pull/679) -* Add gemini tutorial by [@jlowin](https://github.com/jlowin) in [#680](https://github.com/PrefectHQ/fastmcp/pull/680) -* Fix : fix path error to CLI Documentation by [@yrangana](https://github.com/yrangana) in [#684](https://github.com/PrefectHQ/fastmcp/pull/684) -* Update auth docs by [@jlowin](https://github.com/jlowin) in [#687](https://github.com/PrefectHQ/fastmcp/pull/687) - -### Other Changes 🦾 - -* Remove deprecation notice by [@jlowin](https://github.com/jlowin) in [#677](https://github.com/PrefectHQ/fastmcp/pull/677) -* Delete server.py by [@jlowin](https://github.com/jlowin) in [#681](https://github.com/PrefectHQ/fastmcp/pull/681) -* Restore py.typed by [@jlowin](https://github.com/jlowin) in [#686](https://github.com/PrefectHQ/fastmcp/pull/686) - -### New Contributors - -* [@yrangana](https://github.com/yrangana) made their first contribution in [#684](https://github.com/PrefectHQ/fastmcp/pull/684) - -**Full Changelog**: [v2.6.0...v2.6.1](https://github.com/PrefectHQ/fastmcp/compare/v2.6.0...v2.6.1) -</Update> - -<Update label="v2.6.0" description="2024-06-02"> - -## [v2.6.0: Blast Auth](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.6.0) - -### New Features 🎉 - -* Introduce MCP client oauth flow by [@jlowin](https://github.com/jlowin) in [#478](https://github.com/PrefectHQ/fastmcp/pull/478) -* Support providing tools at init by [@jlowin](https://github.com/jlowin) in [#647](https://github.com/PrefectHQ/fastmcp/pull/647) -* Simplify code for running servers in processes during tests by [@jlowin](https://github.com/jlowin) in [#649](https://github.com/PrefectHQ/fastmcp/pull/649) -* Add basic bearer auth for server and client by [@jlowin](https://github.com/jlowin) in [#650](https://github.com/PrefectHQ/fastmcp/pull/650) -* Support configuring bearer auth from env vars by [@jlowin](https://github.com/jlowin) in [#652](https://github.com/PrefectHQ/fastmcp/pull/652) -* feat(tool): add support for excluding arguments from tool definition by [@deepak-stratforge](https://github.com/deepak-stratforge) in [#626](https://github.com/PrefectHQ/fastmcp/pull/626) -* Add docs for server + client auth by [@jlowin](https://github.com/jlowin) in [#655](https://github.com/PrefectHQ/fastmcp/pull/655) - -### Fixes 🐞 - -* fix: Support concurrency in FastMcpProxy (and Client) by [@Sillocan](https://github.com/Sillocan) in [#635](https://github.com/PrefectHQ/fastmcp/pull/635) -* Ensure Client.close() cleans up client context appropriately by [@jlowin](https://github.com/jlowin) in [#643](https://github.com/PrefectHQ/fastmcp/pull/643) -* Update client.mdx: ClientError namespace by [@mjkaye](https://github.com/mjkaye) in [#657](https://github.com/PrefectHQ/fastmcp/pull/657) - -### Docs 📚 - -* Make FastMCPTransport support simulated Streamable HTTP Transport (didn't work) by [@jlowin](https://github.com/jlowin) in [#645](https://github.com/PrefectHQ/fastmcp/pull/645) -* Document exclude\_args by [@jlowin](https://github.com/jlowin) in [#653](https://github.com/PrefectHQ/fastmcp/pull/653) -* Update welcome by [@jlowin](https://github.com/jlowin) in [#673](https://github.com/PrefectHQ/fastmcp/pull/673) -* Add Anthropic + Claude desktop integration guides by [@jlowin](https://github.com/jlowin) in [#674](https://github.com/PrefectHQ/fastmcp/pull/674) -* Minor docs design updates by [@jlowin](https://github.com/jlowin) in [#676](https://github.com/PrefectHQ/fastmcp/pull/676) - -### Other Changes 🦾 - -* Update test typing by [@jlowin](https://github.com/jlowin) in [#646](https://github.com/PrefectHQ/fastmcp/pull/646) -* Add OpenAI integration docs by [@jlowin](https://github.com/jlowin) in [#660](https://github.com/PrefectHQ/fastmcp/pull/660) - -### New Contributors - -* [@Sillocan](https://github.com/Sillocan) made their first contribution in [#635](https://github.com/PrefectHQ/fastmcp/pull/635) -* [@deepak-stratforge](https://github.com/deepak-stratforge) made their first contribution in [#626](https://github.com/PrefectHQ/fastmcp/pull/626) -* [@mjkaye](https://github.com/mjkaye) made their first contribution in [#657](https://github.com/PrefectHQ/fastmcp/pull/657) - -**Full Changelog**: [v2.5.2...v2.6.0](https://github.com/PrefectHQ/fastmcp/compare/v2.5.2...v2.6.0) -</Update> - -<Update label="v2.5.2" description="2024-05-29"> - -## [v2.5.2: Stayin' Alive](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.5.2) - -### New Features 🎉 - -* Add graceful error handling for unreachable mounted servers by [@davenpi](https://github.com/davenpi) in [#605](https://github.com/PrefectHQ/fastmcp/pull/605) -* Improve type inference from client transport by [@jlowin](https://github.com/jlowin) in [#623](https://github.com/PrefectHQ/fastmcp/pull/623) -* Add keep\_alive param to reuse subprocess by [@jlowin](https://github.com/jlowin) in [#624](https://github.com/PrefectHQ/fastmcp/pull/624) - -### Fixes 🐞 - -* Fix handling tools without descriptions by [@jlowin](https://github.com/jlowin) in [#610](https://github.com/PrefectHQ/fastmcp/pull/610) -* Don't print env vars to console when format is wrong by [@jlowin](https://github.com/jlowin) in [#615](https://github.com/PrefectHQ/fastmcp/pull/615) -* Ensure behavior-affecting headers are excluded when forwarding proxies/openapi by [@jlowin](https://github.com/jlowin) in [#620](https://github.com/PrefectHQ/fastmcp/pull/620) - -### Docs 📚 - -* Add notes about uv and claude desktop by [@jlowin](https://github.com/jlowin) in [#597](https://github.com/PrefectHQ/fastmcp/pull/597) - -### Other Changes 🦾 - -* add init\_timeout for mcp client by [@jfouret](https://github.com/jfouret) in [#607](https://github.com/PrefectHQ/fastmcp/pull/607) -* Add init\_timeout for mcp client (incl settings) by [@jlowin](https://github.com/jlowin) in [#609](https://github.com/PrefectHQ/fastmcp/pull/609) -* Support for uppercase letters at the log level by [@ksawaray](https://github.com/ksawaray) in [#625](https://github.com/PrefectHQ/fastmcp/pull/625) - -### New Contributors - -* [@jfouret](https://github.com/jfouret) made their first contribution in [#607](https://github.com/PrefectHQ/fastmcp/pull/607) -* [@ksawaray](https://github.com/ksawaray) made their first contribution in [#625](https://github.com/PrefectHQ/fastmcp/pull/625) - -**Full Changelog**: [v2.5.1...v2.5.2](https://github.com/PrefectHQ/fastmcp/compare/v2.5.1...v2.5.2) -</Update> - -<Update label="v2.5.1" description="2024-05-24"> - -## [v2.5.1: Route Awakening (Part 2)](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.5.1) - -### Fixes 🐞 - -* Ensure content-length is always stripped from client headers by [@jlowin](https://github.com/jlowin) in [#589](https://github.com/PrefectHQ/fastmcp/pull/589) - -### Docs 📚 - -* Fix redundant section of docs by [@jlowin](https://github.com/jlowin) in [#583](https://github.com/PrefectHQ/fastmcp/pull/583) - -**Full Changelog**: [v2.5.0...v2.5.1](https://github.com/PrefectHQ/fastmcp/compare/v2.5.0...v2.5.1) -</Update> - -<Update label="v2.5.0" description="2024-05-24"> - -## [v2.5.0: Route Awakening](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.5.0) - -This release introduces completely new tools for generating and customizing MCP servers from OpenAPI specs and FastAPI apps, including popular requests like mechanisms for determining what routes map to what MCP components; renaming routes; and customizing the generated MCP components. - -### New Features 🎉 - -* Add FastMCP 1.0 server support for in-memory Client / Testing by [@jlowin](https://github.com/jlowin) in [#539](https://github.com/PrefectHQ/fastmcp/pull/539) -* Minor addition: add transport to stdio server in mcpconfig, with default by [@jlowin](https://github.com/jlowin) in [#555](https://github.com/PrefectHQ/fastmcp/pull/555) -* Raise an error if a Client is created with no servers in config by [@jlowin](https://github.com/jlowin) in [#554](https://github.com/PrefectHQ/fastmcp/pull/554) -* Expose model preferences in `Context.sample` for flexible model selection. by [@davenpi](https://github.com/davenpi) in [#542](https://github.com/PrefectHQ/fastmcp/pull/542) -* Ensure custom routes are respected by [@jlowin](https://github.com/jlowin) in [#558](https://github.com/PrefectHQ/fastmcp/pull/558) -* Add client method to send cancellation notifications by [@davenpi](https://github.com/davenpi) in [#563](https://github.com/PrefectHQ/fastmcp/pull/563) -* Enhance route map logic for include/exclude OpenAPI routes by [@jlowin](https://github.com/jlowin) in [#564](https://github.com/PrefectHQ/fastmcp/pull/564) -* Add tag-based route maps by [@jlowin](https://github.com/jlowin) in [#565](https://github.com/PrefectHQ/fastmcp/pull/565) -* Add advanced control of openAPI route creation by [@jlowin](https://github.com/jlowin) in [#566](https://github.com/PrefectHQ/fastmcp/pull/566) -* Make error masking configurable by [@jlowin](https://github.com/jlowin) in [#550](https://github.com/PrefectHQ/fastmcp/pull/550) -* Ensure client headers are passed through to remote servers by [@jlowin](https://github.com/jlowin) in [#575](https://github.com/PrefectHQ/fastmcp/pull/575) -* Use lowercase name for headers when comparing by [@jlowin](https://github.com/jlowin) in [#576](https://github.com/PrefectHQ/fastmcp/pull/576) -* Permit more flexible name generation for OpenAPI servers by [@jlowin](https://github.com/jlowin) in [#578](https://github.com/PrefectHQ/fastmcp/pull/578) -* Ensure that tools/templates/prompts are compatible with callable objects by [@jlowin](https://github.com/jlowin) in [#579](https://github.com/PrefectHQ/fastmcp/pull/579) - -### Docs 📚 - -* Add version badge for prefix formats by [@jlowin](https://github.com/jlowin) in [#537](https://github.com/PrefectHQ/fastmcp/pull/537) -* Add versioning note to docs by [@jlowin](https://github.com/jlowin) in [#551](https://github.com/PrefectHQ/fastmcp/pull/551) -* Bump 2.3.6 references to 2.4.0 by [@jlowin](https://github.com/jlowin) in [#567](https://github.com/PrefectHQ/fastmcp/pull/567) - -**Full Changelog**: [v2.4.0...v2.5.0](https://github.com/PrefectHQ/fastmcp/compare/v2.4.0...v2.5.0) -</Update> - -<Update label="v2.4.0" description="2024-05-21"> - -## [v2.4.0: Config and Conquer](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.4.0) - -**Note**: this release includes a backwards-incompatible change to how resources are prefixed when mounted in composed servers. However, it is only backwards-incompatible if users were running tests or manually loading resources by prefixed key; LLMs should not have any issue discovering the new route. - -### New Features 🎉 - -* Allow \* Methods and all routes as tools shortcuts by [@jlowin](https://github.com/jlowin) in [#520](https://github.com/PrefectHQ/fastmcp/pull/520) -* Improved support for config dicts by [@jlowin](https://github.com/jlowin) in [#522](https://github.com/PrefectHQ/fastmcp/pull/522) -* Support creating clients from MCP config dicts, including multi-server clients by [@jlowin](https://github.com/jlowin) in [#527](https://github.com/PrefectHQ/fastmcp/pull/527) -* Make resource prefix format configurable by [@jlowin](https://github.com/jlowin) in [#534](https://github.com/PrefectHQ/fastmcp/pull/534) - -### Fixes 🐞 - -* Avoid hanging on initializing server session by [@jlowin](https://github.com/jlowin) in [#523](https://github.com/PrefectHQ/fastmcp/pull/523) - -### Breaking Changes 🛫 - -* Remove customizable separators; improve resource separator by [@jlowin](https://github.com/jlowin) in [#526](https://github.com/PrefectHQ/fastmcp/pull/526) - -### Docs 📚 - -* Improve client documentation by [@jlowin](https://github.com/jlowin) in [#517](https://github.com/PrefectHQ/fastmcp/pull/517) - -### Other Changes 🦾 - -* Ensure openapi path params are handled properly by [@jlowin](https://github.com/jlowin) in [#519](https://github.com/PrefectHQ/fastmcp/pull/519) -* better error when missing lifespan by [@zzstoatzz](https://github.com/zzstoatzz) in [#521](https://github.com/PrefectHQ/fastmcp/pull/521) - -**Full Changelog**: [v2.3.5...v2.4.0](https://github.com/PrefectHQ/fastmcp/compare/v2.3.5...v2.4.0) -</Update> - -<Update label="v2.3.5" description="2024-05-20"> - -## [v2.3.5: Making Progress](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.5) - -### New Features 🎉 - -* support messages in progress notifications by [@rickygenhealth](https://github.com/rickygenhealth) in [#471](https://github.com/PrefectHQ/fastmcp/pull/471) -* feat: Add middleware option in server.run by [@Maxi91f](https://github.com/Maxi91f) in [#475](https://github.com/PrefectHQ/fastmcp/pull/475) -* Add lifespan property to app by [@jlowin](https://github.com/jlowin) in [#483](https://github.com/PrefectHQ/fastmcp/pull/483) -* Update `fastmcp run` to work with remote servers by [@jlowin](https://github.com/jlowin) in [#491](https://github.com/PrefectHQ/fastmcp/pull/491) -* Add FastMCP.as\_proxy() by [@jlowin](https://github.com/jlowin) in [#490](https://github.com/PrefectHQ/fastmcp/pull/490) -* Infer sse transport from urls containing /sse by [@jlowin](https://github.com/jlowin) in [#512](https://github.com/PrefectHQ/fastmcp/pull/512) -* Add progress handler to client by [@jlowin](https://github.com/jlowin) in [#513](https://github.com/PrefectHQ/fastmcp/pull/513) -* Store the initialize result on the client by [@jlowin](https://github.com/jlowin) in [#509](https://github.com/PrefectHQ/fastmcp/pull/509) - -### Fixes 🐞 - -* Remove patch and use upstream SSEServerTransport by [@jlowin](https://github.com/jlowin) in [#425](https://github.com/PrefectHQ/fastmcp/pull/425) - -### Docs 📚 - -* Update transport docs by [@jlowin](https://github.com/jlowin) in [#458](https://github.com/PrefectHQ/fastmcp/pull/458) -* update proxy docs + example by [@zzstoatzz](https://github.com/zzstoatzz) in [#460](https://github.com/PrefectHQ/fastmcp/pull/460) -* doc(asgi): Change custom route example to PlainTextResponse by [@mcw0933](https://github.com/mcw0933) in [#477](https://github.com/PrefectHQ/fastmcp/pull/477) -* Store FastMCP instance on app.state.fastmcp\_server by [@jlowin](https://github.com/jlowin) in [#489](https://github.com/PrefectHQ/fastmcp/pull/489) -* Improve AGENTS.md overview by [@jlowin](https://github.com/jlowin) in [#492](https://github.com/PrefectHQ/fastmcp/pull/492) -* Update release numbers for anticipated version by [@jlowin](https://github.com/jlowin) in [#516](https://github.com/PrefectHQ/fastmcp/pull/516) - -### Other Changes 🦾 - -* run tests on all PRs by [@jlowin](https://github.com/jlowin) in [#468](https://github.com/PrefectHQ/fastmcp/pull/468) -* add null check by [@zzstoatzz](https://github.com/zzstoatzz) in [#473](https://github.com/PrefectHQ/fastmcp/pull/473) -* strict typing for `server.py` by [@zzstoatzz](https://github.com/zzstoatzz) in [#476](https://github.com/PrefectHQ/fastmcp/pull/476) -* Doc(quickstart): Fix import statements by [@mai-nakagawa](https://github.com/mai-nakagawa) in [#479](https://github.com/PrefectHQ/fastmcp/pull/479) -* Add labeler by [@jlowin](https://github.com/jlowin) in [#484](https://github.com/PrefectHQ/fastmcp/pull/484) -* Fix flaky timeout test by increasing timeout (#474) by [@davenpi](https://github.com/davenpi) in [#486](https://github.com/PrefectHQ/fastmcp/pull/486) -* Skipping `test_permission_error` if runner is root. by [@ZiadAmerr](https://github.com/ZiadAmerr) in [#502](https://github.com/PrefectHQ/fastmcp/pull/502) -* allow passing full uvicorn config by [@zzstoatzz](https://github.com/zzstoatzz) in [#504](https://github.com/PrefectHQ/fastmcp/pull/504) -* Skip timeout tests on windows by [@jlowin](https://github.com/jlowin) in [#514](https://github.com/PrefectHQ/fastmcp/pull/514) - -### New Contributors - -* [@rickygenhealth](https://github.com/rickygenhealth) made their first contribution in [#471](https://github.com/PrefectHQ/fastmcp/pull/471) -* [@Maxi91f](https://github.com/Maxi91f) made their first contribution in [#475](https://github.com/PrefectHQ/fastmcp/pull/475) -* [@mcw0933](https://github.com/mcw0933) made their first contribution in [#477](https://github.com/PrefectHQ/fastmcp/pull/477) -* [@mai-nakagawa](https://github.com/mai-nakagawa) made their first contribution in [#479](https://github.com/PrefectHQ/fastmcp/pull/479) -* [@ZiadAmerr](https://github.com/ZiadAmerr) made their first contribution in [#502](https://github.com/PrefectHQ/fastmcp/pull/502) - -**Full Changelog**: [v2.3.4...v2.3.5](https://github.com/PrefectHQ/fastmcp/compare/v2.3.4...v2.3.5) -</Update> - -<Update label="v2.3.4" description="2024-05-15"> - -## [v2.3.4: Error Today, Gone Tomorrow](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.4) - -### New Features 🎉 - -* logging stack trace for easier debugging by [@jbkoh](https://github.com/jbkoh) in [#413](https://github.com/PrefectHQ/fastmcp/pull/413) -* add missing StreamableHttpTransport in client exports by [@yihuang](https://github.com/yihuang) in [#408](https://github.com/PrefectHQ/fastmcp/pull/408) -* Improve error handling for tools and resources by [@jlowin](https://github.com/jlowin) in [#434](https://github.com/PrefectHQ/fastmcp/pull/434) -* feat: add support for removing tools from server by [@davenpi](https://github.com/davenpi) in [#437](https://github.com/PrefectHQ/fastmcp/pull/437) -* Prune titles from JSONSchemas by [@jlowin](https://github.com/jlowin) in [#449](https://github.com/PrefectHQ/fastmcp/pull/449) -* Declare toolsChanged capability for stdio server. by [@davenpi](https://github.com/davenpi) in [#450](https://github.com/PrefectHQ/fastmcp/pull/450) -* Improve handling of exceptiongroups when raised in clients by [@jlowin](https://github.com/jlowin) in [#452](https://github.com/PrefectHQ/fastmcp/pull/452) -* Add timeout support to client by [@jlowin](https://github.com/jlowin) in [#455](https://github.com/PrefectHQ/fastmcp/pull/455) - -### Fixes 🐞 - -* Pin to mcp 1.8.1 to resolve callback deadlocks with SHTTP by [@jlowin](https://github.com/jlowin) in [#427](https://github.com/PrefectHQ/fastmcp/pull/427) -* Add reprs for OpenAPI objects by [@jlowin](https://github.com/jlowin) in [#447](https://github.com/PrefectHQ/fastmcp/pull/447) -* Ensure openapi defs for structured objects are loaded properly by [@jlowin](https://github.com/jlowin) in [#448](https://github.com/PrefectHQ/fastmcp/pull/448) -* Ensure tests run against correct python version by [@jlowin](https://github.com/jlowin) in [#454](https://github.com/PrefectHQ/fastmcp/pull/454) -* Ensure result is only returned if a new key was found by [@jlowin](https://github.com/jlowin) in [#456](https://github.com/PrefectHQ/fastmcp/pull/456) - -### Docs 📚 - -* Add documentation for tool removal by [@jlowin](https://github.com/jlowin) in [#440](https://github.com/PrefectHQ/fastmcp/pull/440) - -### Other Changes 🦾 - -* Deprecate passing settings to the FastMCP instance by [@jlowin](https://github.com/jlowin) in [#424](https://github.com/PrefectHQ/fastmcp/pull/424) -* Add path prefix to test by [@jlowin](https://github.com/jlowin) in [#432](https://github.com/PrefectHQ/fastmcp/pull/432) - -### New Contributors - -* [@jbkoh](https://github.com/jbkoh) made their first contribution in [#413](https://github.com/PrefectHQ/fastmcp/pull/413) -* [@davenpi](https://github.com/davenpi) made their first contribution in [#437](https://github.com/PrefectHQ/fastmcp/pull/437) - -**Full Changelog**: [v2.3.3...v2.3.4](https://github.com/PrefectHQ/fastmcp/compare/v2.3.3...v2.3.4) -</Update> - -<Update label="v2.3.3" description="2024-05-10"> - -## [v2.3.3: SSE you later](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.3) - -This is a hotfix for a bug introduced in 2.3.2 that broke SSE servers - -### Fixes 🐞 - -* Fix bug that sets message path and sse path to same value by [@jlowin](https://github.com/jlowin) in [#405](https://github.com/PrefectHQ/fastmcp/pull/405) - -### Docs 📚 - -* Update composition docs by [@jlowin](https://github.com/jlowin) in [#403](https://github.com/PrefectHQ/fastmcp/pull/403) - -### Other Changes 🦾 - -* Add test for no prefix when importing by [@jlowin](https://github.com/jlowin) in [#404](https://github.com/PrefectHQ/fastmcp/pull/404) - -**Full Changelog**: [v2.3.2...v2.3.3](https://github.com/PrefectHQ/fastmcp/compare/v2.3.2...v2.3.3) -</Update> - -<Update label="v2.3.2" description="2024-05-10"> - -## [v2.3.2: Stuck in the Middleware With You](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.2) - -### New Features 🎉 - -* Allow users to pass middleware to starlette app constructors by [@jlowin](https://github.com/jlowin) in [#398](https://github.com/PrefectHQ/fastmcp/pull/398) -* Deprecate transport-specific methods on FastMCP server by [@jlowin](https://github.com/jlowin) in [#401](https://github.com/PrefectHQ/fastmcp/pull/401) - -### Docs 📚 - -* Update CLI docs by [@jlowin](https://github.com/jlowin) in [#402](https://github.com/PrefectHQ/fastmcp/pull/402) - -### Other Changes 🦾 - -* Adding 23 tests for CLI by [@didier-durand](https://github.com/didier-durand) in [#394](https://github.com/PrefectHQ/fastmcp/pull/394) - -**Full Changelog**: [v2.3.1...v2.3.2](https://github.com/PrefectHQ/fastmcp/compare/v2.3.1...v2.3.2) -</Update> - -<Update label="v2.3.1" description="2024-05-09"> - -## [v2.3.1: For Good-nests Sake](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.1) - -This release primarily patches a long-standing bug with nested ASGI SSE servers. - -### Fixes 🐞 - -* Fix tool result serialization when the tool returns a list by [@strawgate](https://github.com/strawgate) in [#379](https://github.com/PrefectHQ/fastmcp/pull/379) -* Ensure FastMCP handles nested SSE and SHTTP apps properly in ASGI frameworks by [@jlowin](https://github.com/jlowin) in [#390](https://github.com/PrefectHQ/fastmcp/pull/390) - -### Docs 📚 - -* Update transport docs by [@jlowin](https://github.com/jlowin) in [#377](https://github.com/PrefectHQ/fastmcp/pull/377) -* Add llms.txt to docs by [@jlowin](https://github.com/jlowin) in [#384](https://github.com/PrefectHQ/fastmcp/pull/384) -* Fixing various text typos by [@didier-durand](https://github.com/didier-durand) in [#385](https://github.com/PrefectHQ/fastmcp/pull/385) - -### Other Changes 🦾 - -* Adding a few tests to Image type by [@didier-durand](https://github.com/didier-durand) in [#387](https://github.com/PrefectHQ/fastmcp/pull/387) -* Adding tests for TimedCache by [@didier-durand](https://github.com/didier-durand) in [#388](https://github.com/PrefectHQ/fastmcp/pull/388) - -### New Contributors - -* [@didier-durand](https://github.com/didier-durand) made their first contribution in [#385](https://github.com/PrefectHQ/fastmcp/pull/385) - -**Full Changelog**: [v2.3.0...v2.3.1](https://github.com/PrefectHQ/fastmcp/compare/v2.3.0...v2.3.1) -</Update> - -<Update label="v2.3.0" description="2024-05-08"> - -## [v2.3.0: Stream Me Up, Scotty](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.0) - -### New Features 🎉 - -* Add streaming support for HTTP transport by [@jlowin](https://github.com/jlowin) in [#365](https://github.com/PrefectHQ/fastmcp/pull/365) -* Support streaming HTTP transport in clients by [@jlowin](https://github.com/jlowin) in [#366](https://github.com/PrefectHQ/fastmcp/pull/366) -* Add streaming support to CLI by [@jlowin](https://github.com/jlowin) in [#367](https://github.com/PrefectHQ/fastmcp/pull/367) - -### Fixes 🐞 - -* Fix streaming transport initialization by [@jlowin](https://github.com/jlowin) in [#368](https://github.com/PrefectHQ/fastmcp/pull/368) - -### Docs 📚 - -* Update transport documentation for streaming support by [@jlowin](https://github.com/jlowin) in [#369](https://github.com/PrefectHQ/fastmcp/pull/369) - -**Full Changelog**: [v2.2.10...v2.3.0](https://github.com/PrefectHQ/fastmcp/compare/v2.2.10...v2.3.0) -</Update> - -<Update label="v2.2.10" description="2024-05-06"> - -## [v2.2.10: That's JSON Bourne](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.10) - -### Fixes 🐞 - -* Disable automatic JSON parsing of tool args by [@jlowin](https://github.com/jlowin) in [#341](https://github.com/PrefectHQ/fastmcp/pull/341) -* Fix prompt test by [@jlowin](https://github.com/jlowin) in [#342](https://github.com/PrefectHQ/fastmcp/pull/342) - -### Other Changes 🦾 - -* Update docs.json by [@jlowin](https://github.com/jlowin) in [#338](https://github.com/PrefectHQ/fastmcp/pull/338) -* Add test coverage + tests on 4 examples by [@alainivars](https://github.com/alainivars) in [#306](https://github.com/PrefectHQ/fastmcp/pull/306) - -### New Contributors - -* [@alainivars](https://github.com/alainivars) made their first contribution in [#306](https://github.com/PrefectHQ/fastmcp/pull/306) - -**Full Changelog**: [v2.2.9...v2.2.10](https://github.com/PrefectHQ/fastmcp/compare/v2.2.9...v2.2.10) -</Update> - -<Update label="v2.2.9" description="2024-05-06"> - -## [v2.2.9: Str-ing the Pot (Hotfix)](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.9) - -This release is a hotfix for the issue detailed in #330 - -### Fixes 🐞 - -* Prevent invalid resource URIs by [@jlowin](https://github.com/jlowin) in [#336](https://github.com/PrefectHQ/fastmcp/pull/336) -* Coerce numbers to str by [@jlowin](https://github.com/jlowin) in [#337](https://github.com/PrefectHQ/fastmcp/pull/337) - -### Docs 📚 - -* Add client badge by [@jlowin](https://github.com/jlowin) in [#327](https://github.com/PrefectHQ/fastmcp/pull/327) -* Update bug.yml by [@jlowin](https://github.com/jlowin) in [#328](https://github.com/PrefectHQ/fastmcp/pull/328) - -### Other Changes 🦾 - -* Update quickstart.mdx example to include import by [@discdiver](https://github.com/discdiver) in [#329](https://github.com/PrefectHQ/fastmcp/pull/329) - -### New Contributors - -* [@discdiver](https://github.com/discdiver) made their first contribution in [#329](https://github.com/PrefectHQ/fastmcp/pull/329) - -**Full Changelog**: [v2.2.8...v2.2.9](https://github.com/PrefectHQ/fastmcp/compare/v2.2.8...v2.2.9) -</Update> - -<Update label="v2.2.8" description="2024-05-05"> - -## [v2.2.8: Parse and Recreation](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.8) - -### New Features 🎉 - -* Replace custom parsing with TypeAdapter by [@jlowin](https://github.com/jlowin) in [#314](https://github.com/PrefectHQ/fastmcp/pull/314) -* Handle \*args/\*\*kwargs appropriately for various components by [@jlowin](https://github.com/jlowin) in [#317](https://github.com/PrefectHQ/fastmcp/pull/317) -* Add timeout-graceful-shutdown as a default config for SSE app by [@jlowin](https://github.com/jlowin) in [#323](https://github.com/PrefectHQ/fastmcp/pull/323) -* Ensure prompts return descriptions by [@jlowin](https://github.com/jlowin) in [#325](https://github.com/PrefectHQ/fastmcp/pull/325) - -### Fixes 🐞 - -* Ensure that tool serialization has a graceful fallback by [@jlowin](https://github.com/jlowin) in [#310](https://github.com/PrefectHQ/fastmcp/pull/310) - -### Docs 📚 - -* Update docs for clarity by [@jlowin](https://github.com/jlowin) in [#312](https://github.com/PrefectHQ/fastmcp/pull/312) - -### Other Changes 🦾 - -* Remove is\_async attribute by [@jlowin](https://github.com/jlowin) in [#315](https://github.com/PrefectHQ/fastmcp/pull/315) -* Dry out retrieving context kwarg by [@jlowin](https://github.com/jlowin) in [#316](https://github.com/PrefectHQ/fastmcp/pull/316) - -**Full Changelog**: [v2.2.7...v2.2.8](https://github.com/PrefectHQ/fastmcp/compare/v2.2.7...v2.2.8) -</Update> - -<Update label="v2.2.7" description="2024-05-03"> - -## [v2.2.7: You Auth to Know Better](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.7) - -### New Features 🎉 - -* use pydantic\_core.to\_json by [@jlowin](https://github.com/jlowin) in [#290](https://github.com/PrefectHQ/fastmcp/pull/290) -* Ensure openapi descriptions are included in tool details by [@jlowin](https://github.com/jlowin) in [#293](https://github.com/PrefectHQ/fastmcp/pull/293) -* Bump mcp to 1.7.1 by [@jlowin](https://github.com/jlowin) in [#298](https://github.com/PrefectHQ/fastmcp/pull/298) -* Add support for tool annotations by [@jlowin](https://github.com/jlowin) in [#299](https://github.com/PrefectHQ/fastmcp/pull/299) -* Add auth support by [@jlowin](https://github.com/jlowin) in [#300](https://github.com/PrefectHQ/fastmcp/pull/300) -* Add low-level methods to client by [@jlowin](https://github.com/jlowin) in [#301](https://github.com/PrefectHQ/fastmcp/pull/301) -* Add method for retrieving current starlette request to FastMCP context by [@jlowin](https://github.com/jlowin) in [#302](https://github.com/PrefectHQ/fastmcp/pull/302) -* get\_starlette\_request → get\_http\_request by [@jlowin](https://github.com/jlowin) in [#303](https://github.com/PrefectHQ/fastmcp/pull/303) -* Support custom Serializer for Tools by [@strawgate](https://github.com/strawgate) in [#308](https://github.com/PrefectHQ/fastmcp/pull/308) -* Support proxy mount by [@jlowin](https://github.com/jlowin) in [#309](https://github.com/PrefectHQ/fastmcp/pull/309) - -### Other Changes 🦾 - -* Improve context injection type checks by [@jlowin](https://github.com/jlowin) in [#291](https://github.com/PrefectHQ/fastmcp/pull/291) -* add readme to smarthome example by [@zzstoatzz](https://github.com/zzstoatzz) in [#294](https://github.com/PrefectHQ/fastmcp/pull/294) - -**Full Changelog**: [v2.2.6...v2.2.7](https://github.com/PrefectHQ/fastmcp/compare/v2.2.6...v2.2.7) -</Update> - -<Update label="v2.2.6" description="2024-04-30"> - -## [v2.2.6: The REST is History](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.6) - -### New Features 🎉 - -* Added feature : Load MCP server using config by [@sandipan1](https://github.com/sandipan1) in [#260](https://github.com/PrefectHQ/fastmcp/pull/260) -* small typing fixes by [@zzstoatzz](https://github.com/zzstoatzz) in [#237](https://github.com/PrefectHQ/fastmcp/pull/237) -* Expose configurable timeout for OpenAPI by [@jlowin](https://github.com/jlowin) in [#279](https://github.com/PrefectHQ/fastmcp/pull/279) -* Lower websockets pin for compatibility by [@jlowin](https://github.com/jlowin) in [#286](https://github.com/PrefectHQ/fastmcp/pull/286) -* Improve OpenAPI param handling by [@jlowin](https://github.com/jlowin) in [#287](https://github.com/PrefectHQ/fastmcp/pull/287) - -### Fixes 🐞 - -* Ensure openapi tool responses are properly converted by [@jlowin](https://github.com/jlowin) in [#283](https://github.com/PrefectHQ/fastmcp/pull/283) -* Fix OpenAPI examples by [@jlowin](https://github.com/jlowin) in [#285](https://github.com/PrefectHQ/fastmcp/pull/285) -* Fix client docs for advanced features, add tests for logging by [@jlowin](https://github.com/jlowin) in [#284](https://github.com/PrefectHQ/fastmcp/pull/284) - -### Other Changes 🦾 - -* add testing doc by [@jlowin](https://github.com/jlowin) in [#264](https://github.com/PrefectHQ/fastmcp/pull/264) -* #267 Fix openapi template resource to support multiple path parameters by [@jeger-at](https://github.com/jeger-at) in [#278](https://github.com/PrefectHQ/fastmcp/pull/278) - -### New Contributors - -* [@sandipan1](https://github.com/sandipan1) made their first contribution in [#260](https://github.com/PrefectHQ/fastmcp/pull/260) -* [@jeger-at](https://github.com/jeger-at) made their first contribution in [#278](https://github.com/PrefectHQ/fastmcp/pull/278) - -**Full Changelog**: [v2.2.5...v2.2.6](https://github.com/PrefectHQ/fastmcp/compare/v2.2.5...v2.2.6) -</Update> - -<Update label="v2.2.5" description="2024-04-26"> - -## [v2.2.5: Context Switching](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.5) - -### New Features 🎉 - -* Add tests for tool return types; improve serialization behavior by [@jlowin](https://github.com/jlowin) in [#262](https://github.com/PrefectHQ/fastmcp/pull/262) -* Support context injection in resources, templates, and prompts (like tools) by [@jlowin](https://github.com/jlowin) in [#263](https://github.com/PrefectHQ/fastmcp/pull/263) - -### Docs 📚 - -* Update wildcards to 2.2.4 by [@jlowin](https://github.com/jlowin) in [#257](https://github.com/PrefectHQ/fastmcp/pull/257) -* Update note in templates docs by [@jlowin](https://github.com/jlowin) in [#258](https://github.com/PrefectHQ/fastmcp/pull/258) -* Significant documentation and test expansion for tool input types by [@jlowin](https://github.com/jlowin) in [#261](https://github.com/PrefectHQ/fastmcp/pull/261) - -**Full Changelog**: [v2.2.4...v2.2.5](https://github.com/PrefectHQ/fastmcp/compare/v2.2.4...v2.2.5) -</Update> - -<Update label="v2.2.4" description="2024-04-25"> - -## [v2.2.4: The Wild Side, Actually](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.4) - -The wildcard URI templates exposed in v2.2.3 were blocked by a server-level check which is removed in this release. - -### New Features 🎉 - -* Allow customization of inspector proxy port, ui port, and version by [@jlowin](https://github.com/jlowin) in [#253](https://github.com/PrefectHQ/fastmcp/pull/253) - -### Fixes 🐞 - -* fix: unintended type convert by [@cutekibry](https://github.com/cutekibry) in [#252](https://github.com/PrefectHQ/fastmcp/pull/252) -* Ensure openapi resources return valid responses by [@jlowin](https://github.com/jlowin) in [#254](https://github.com/PrefectHQ/fastmcp/pull/254) -* Ensure servers expose template wildcards by [@jlowin](https://github.com/jlowin) in [#256](https://github.com/PrefectHQ/fastmcp/pull/256) - -### Docs 📚 - -* Update README.md Grammar error by [@TechWithTy](https://github.com/TechWithTy) in [#249](https://github.com/PrefectHQ/fastmcp/pull/249) - -### Other Changes 🦾 - -* Add resource template tests by [@jlowin](https://github.com/jlowin) in [#255](https://github.com/PrefectHQ/fastmcp/pull/255) - -### New Contributors - -* [@TechWithTy](https://github.com/TechWithTy) made their first contribution in [#249](https://github.com/PrefectHQ/fastmcp/pull/249) -* [@cutekibry](https://github.com/cutekibry) made their first contribution in [#252](https://github.com/PrefectHQ/fastmcp/pull/252) - -**Full Changelog**: [v2.2.3...v2.2.4](https://github.com/PrefectHQ/fastmcp/compare/v2.2.3...v2.2.4) -</Update> - -<Update label="v2.2.3" description="2024-04-25"> - -## [v2.2.3: The Wild Side](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.3) - -### New Features 🎉 - -* Add wildcard params for resource templates by [@jlowin](https://github.com/jlowin) in [#246](https://github.com/PrefectHQ/fastmcp/pull/246) - -### Docs 📚 - -* Indicate that Image class is for returns by [@jlowin](https://github.com/jlowin) in [#242](https://github.com/PrefectHQ/fastmcp/pull/242) -* Update mermaid diagram by [@jlowin](https://github.com/jlowin) in [#243](https://github.com/PrefectHQ/fastmcp/pull/243) - -### Other Changes 🦾 - -* update version badges by [@jlowin](https://github.com/jlowin) in [#248](https://github.com/PrefectHQ/fastmcp/pull/248) - -**Full Changelog**: [v2.2.2...v2.2.3](https://github.com/PrefectHQ/fastmcp/compare/v2.2.2...v2.2.3) -</Update> - -<Update label="v2.2.2" description="2024-04-24"> - -## [v2.2.2: Prompt and Circumstance](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.2) - -### New Features 🎉 - -* Add prompt support by [@jlowin](https://github.com/jlowin) in [#235](https://github.com/PrefectHQ/fastmcp/pull/235) - -### Fixes 🐞 - -* Ensure that resource templates are properly exposed by [@jlowin](https://github.com/jlowin) in [#238](https://github.com/PrefectHQ/fastmcp/pull/238) - -### Docs 📚 - -* Update docs for prompts by [@jlowin](https://github.com/jlowin) in [#236](https://github.com/PrefectHQ/fastmcp/pull/236) - -### Other Changes 🦾 - -* Add prompt tests by [@jlowin](https://github.com/jlowin) in [#239](https://github.com/PrefectHQ/fastmcp/pull/239) - -**Full Changelog**: [v2.2.1...v2.2.2](https://github.com/PrefectHQ/fastmcp/compare/v2.2.1...v2.2.2) -</Update> - -<Update label="v2.2.1" description="2024-04-23"> - -## [v2.2.1: Template for Success](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.1) - -### New Features 🎉 - -* Add resource templates by [@jlowin](https://github.com/jlowin) in [#230](https://github.com/PrefectHQ/fastmcp/pull/230) - -### Fixes 🐞 - -* Ensure that resource templates are properly exposed by [@jlowin](https://github.com/jlowin) in [#231](https://github.com/PrefectHQ/fastmcp/pull/231) - -### Docs 📚 - -* Update docs for resource templates by [@jlowin](https://github.com/jlowin) in [#232](https://github.com/PrefectHQ/fastmcp/pull/232) - -### Other Changes 🦾 - -* Add resource template tests by [@jlowin](https://github.com/jlowin) in [#233](https://github.com/PrefectHQ/fastmcp/pull/233) - -**Full Changelog**: [v2.2.0...v2.2.1](https://github.com/PrefectHQ/fastmcp/compare/v2.2.0...v2.2.1) -</Update> - -<Update label="v2.2.0" description="2024-04-22"> - -## [v2.2.0: Compose Yourself](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.0) - -### New Features 🎉 - -* Add support for mounting FastMCP servers by [@jlowin](https://github.com/jlowin) in [#175](https://github.com/PrefectHQ/fastmcp/pull/175) -* Add support for duplicate behavior == ignore by [@jlowin](https://github.com/jlowin) in [#169](https://github.com/PrefectHQ/fastmcp/pull/169) - -### Breaking Changes 🛫 - -* Refactor MCP composition by [@jlowin](https://github.com/jlowin) in [#176](https://github.com/PrefectHQ/fastmcp/pull/176) - -### Docs 📚 - -* Improve integration documentation by [@jlowin](https://github.com/jlowin) in [#184](https://github.com/PrefectHQ/fastmcp/pull/184) -* Improve documentation by [@jlowin](https://github.com/jlowin) in [#185](https://github.com/PrefectHQ/fastmcp/pull/185) - -### Other Changes 🦾 - -* Add transport kwargs for mcp.run() and fastmcp run by [@jlowin](https://github.com/jlowin) in [#161](https://github.com/PrefectHQ/fastmcp/pull/161) -* Allow resource templates to have optional / excluded arguments by [@jlowin](https://github.com/jlowin) in [#164](https://github.com/PrefectHQ/fastmcp/pull/164) -* Update resources.mdx by [@jlowin](https://github.com/jlowin) in [#165](https://github.com/PrefectHQ/fastmcp/pull/165) - -### New Contributors - -* [@kongqi404](https://github.com/kongqi404) made their first contribution in [#181](https://github.com/PrefectHQ/fastmcp/pull/181) - -**Full Changelog**: [v2.1.2...v2.2.0](https://github.com/PrefectHQ/fastmcp/compare/v2.1.2...v2.2.0) -</Update> - -<Update label="v2.1.2" description="2024-04-14"> - -## [v2.1.2: Copy That, Good Buddy](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.1.2) - -The main improvement in this release is a fix that allows FastAPI / OpenAPI-generated servers to be mounted as sub-servers. - -### Fixes 🐞 - -* Ensure objects are copied properly and test mounting fastapi by [@jlowin](https://github.com/jlowin) in [#153](https://github.com/PrefectHQ/fastmcp/pull/153) - -### Docs 📚 - -* Fix broken links in docs by [@jlowin](https://github.com/jlowin) in [#154](https://github.com/PrefectHQ/fastmcp/pull/154) - -### Other Changes 🦾 - -* Update README.md by [@jlowin](https://github.com/jlowin) in [#149](https://github.com/PrefectHQ/fastmcp/pull/149) -* Only apply log config to FastMCP loggers by [@jlowin](https://github.com/jlowin) in [#155](https://github.com/PrefectHQ/fastmcp/pull/155) -* Update pyproject.toml by [@jlowin](https://github.com/jlowin) in [#156](https://github.com/PrefectHQ/fastmcp/pull/156) - -**Full Changelog**: [v2.1.1...v2.1.2](https://github.com/PrefectHQ/fastmcp/compare/v2.1.1...v2.1.2) -</Update> - -<Update label="v2.1.1" description="2024-04-14"> - -## [v2.1.1: Doc Holiday](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.1.1) - -FastMCP's docs are now available at gofastmcp.com. - -### Docs 📚 - -* Add docs by [@jlowin](https://github.com/jlowin) in [#136](https://github.com/PrefectHQ/fastmcp/pull/136) -* Add docs link to readme by [@jlowin](https://github.com/jlowin) in [#137](https://github.com/PrefectHQ/fastmcp/pull/137) -* Minor docs updates by [@jlowin](https://github.com/jlowin) in [#138](https://github.com/PrefectHQ/fastmcp/pull/138) - -### Fixes 🐞 - -* fix branch name in example by [@zzstoatzz](https://github.com/zzstoatzz) in [#140](https://github.com/PrefectHQ/fastmcp/pull/140) - -### Other Changes 🦾 - -* smart home example by [@zzstoatzz](https://github.com/zzstoatzz) in [#115](https://github.com/PrefectHQ/fastmcp/pull/115) -* Remove mac os tests by [@jlowin](https://github.com/jlowin) in [#142](https://github.com/PrefectHQ/fastmcp/pull/142) -* Expand support for various method interactions by [@jlowin](https://github.com/jlowin) in [#143](https://github.com/PrefectHQ/fastmcp/pull/143) -* Update docs and add\_resource\_fn by [@jlowin](https://github.com/jlowin) in [#144](https://github.com/PrefectHQ/fastmcp/pull/144) -* Update description by [@jlowin](https://github.com/jlowin) in [#145](https://github.com/PrefectHQ/fastmcp/pull/145) -* Support openapi 3.0 and 3.1 by [@jlowin](https://github.com/jlowin) in [#147](https://github.com/PrefectHQ/fastmcp/pull/147) - -**Full Changelog**: [v2.1.0...v2.1.1](https://github.com/PrefectHQ/fastmcp/compare/v2.1.0...v2.1.1) -</Update> - -<Update label="v2.1.0" description="2024-04-13"> - -## [v2.1.0: Tag, You're It](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.1.0) - -The primary motivation for this release is the fix in #128 for Claude desktop compatibility, but the primary new feature of this release is per-object tags. Currently these are for bookkeeping only but will become useful in future releases. - -### New Features 🎉 - -* Add tags for all core MCP objects by [@jlowin](https://github.com/jlowin) in [#121](https://github.com/PrefectHQ/fastmcp/pull/121) -* Ensure that openapi tags are transferred to MCP objects by [@jlowin](https://github.com/jlowin) in [#124](https://github.com/PrefectHQ/fastmcp/pull/124) - -### Fixes 🐞 - -* Change default mounted tool separator from / to \_ by [@jlowin](https://github.com/jlowin) in [#128](https://github.com/PrefectHQ/fastmcp/pull/128) -* Enter mounted app lifespans by [@jlowin](https://github.com/jlowin) in [#129](https://github.com/PrefectHQ/fastmcp/pull/129) -* Fix CLI that called mcp instead of fastmcp by [@jlowin](https://github.com/jlowin) in [#128](https://github.com/PrefectHQ/fastmcp/pull/128) - -### Breaking Changes 🛫 - -* Changed configuration for duplicate resources/tools/prompts by [@jlowin](https://github.com/jlowin) in [#121](https://github.com/PrefectHQ/fastmcp/pull/121) -* Improve client return types by [@jlowin](https://github.com/jlowin) in [#123](https://github.com/PrefectHQ/fastmcp/pull/123) - -### Other Changes 🦾 - -* Add tests for tags in server decorators by [@jlowin](https://github.com/jlowin) in [#122](https://github.com/PrefectHQ/fastmcp/pull/122) -* Clean up server tests by [@jlowin](https://github.com/jlowin) in [#125](https://github.com/PrefectHQ/fastmcp/pull/125) - -**Full Changelog**: [v2.0.0...v2.1.0](https://github.com/PrefectHQ/fastmcp/compare/v2.0.0...v2.1.0) -</Update> - -<Update label="v2.0.0" description="2024-04-11"> - -## [v2.0.0: Second to None](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.0.0) - -### New Features 🎉 - -* Support mounting FastMCP instances as sub-MCPs by [@jlowin](https://github.com/jlowin) in [#99](https://github.com/PrefectHQ/fastmcp/pull/99) -* Add in-memory client for calling FastMCP servers (and tests) by [@jlowin](https://github.com/jlowin) in [#100](https://github.com/PrefectHQ/fastmcp/pull/100) -* Add MCP proxy server by [@jlowin](https://github.com/jlowin) in [#105](https://github.com/PrefectHQ/fastmcp/pull/105) -* Update FastMCP for upstream changes by [@jlowin](https://github.com/jlowin) in [#107](https://github.com/PrefectHQ/fastmcp/pull/107) -* Generate FastMCP servers from OpenAPI specs and FastAPI by [@jlowin](https://github.com/jlowin) in [#110](https://github.com/PrefectHQ/fastmcp/pull/110) -* Reorganize all client / transports by [@jlowin](https://github.com/jlowin) in [#111](https://github.com/PrefectHQ/fastmcp/pull/111) -* Add sampling and roots by [@jlowin](https://github.com/jlowin) in [#117](https://github.com/PrefectHQ/fastmcp/pull/117) - -### Fixes 🐞 - -* Fix bug with tools that return lists by [@jlowin](https://github.com/jlowin) in [#116](https://github.com/PrefectHQ/fastmcp/pull/116) - -### Other Changes 🦾 - -* Add back FastMCP CLI by [@jlowin](https://github.com/jlowin) in [#108](https://github.com/PrefectHQ/fastmcp/pull/108) -* Update Readme for v2 by [@jlowin](https://github.com/jlowin) in [#112](https://github.com/PrefectHQ/fastmcp/pull/112) -* fix deprecation warnings by [@zzstoatzz](https://github.com/zzstoatzz) in [#113](https://github.com/PrefectHQ/fastmcp/pull/113) -* Readme by [@jlowin](https://github.com/jlowin) in [#118](https://github.com/PrefectHQ/fastmcp/pull/118) -* FastMCP 2.0 by [@jlowin](https://github.com/jlowin) in [#119](https://github.com/PrefectHQ/fastmcp/pull/119) - -**Full Changelog**: [v1.0...v2.0.0](https://github.com/PrefectHQ/fastmcp/compare/v1.0...v2.0.0) -</Update> - -<Update label="v1.0" description="2024-04-11"> - -## [v1.0: It's Official](https://github.com/PrefectHQ/fastmcp/releases/tag/v1.0) - -This release commemorates FastMCP 1.0, which is included in the official Model Context Protocol SDK: - -```python -from mcp.server.fastmcp import FastMCP -``` - -To the best of my knowledge, v1 is identical to the upstream version included with `mcp`. - -### Docs 📚 - -* Update readme to redirect to the official SDK by [@jlowin](https://github.com/jlowin) in [#79](https://github.com/PrefectHQ/fastmcp/pull/79) - -### Other Changes 🦾 - -* fix: use Mount instead of Route for SSE message handling by [@samihamine](https://github.com/samihamine) in [#77](https://github.com/PrefectHQ/fastmcp/pull/77) - -### New Contributors - -* [@samihamine](https://github.com/samihamine) made their first contribution in [#77](https://github.com/PrefectHQ/fastmcp/pull/77) - -**Full Changelog**: [v0.4.1...v1.0](https://github.com/PrefectHQ/fastmcp/compare/v0.4.1...v1.0) -</Update> - -<Update label="v0.4.1" description="2024-12-09"> - -## [v0.4.1: String Theory](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.4.1) - -### Fixes 🐞 - -* fix: handle strings containing numbers correctly by [@sd2k](https://github.com/sd2k) in [#63](https://github.com/PrefectHQ/fastmcp/pull/63) - -### Docs 📚 - -* patch: Update pyproject.toml license by [@leonkozlowski](https://github.com/leonkozlowski) in [#67](https://github.com/PrefectHQ/fastmcp/pull/67) - -### Other Changes 🦾 - -* Avoid new try\_eval\_type unavailable with older pydantic by [@jurasofish](https://github.com/jurasofish) in [#57](https://github.com/PrefectHQ/fastmcp/pull/57) -* Decorator typing by [@jurasofish](https://github.com/jurasofish) in [#56](https://github.com/PrefectHQ/fastmcp/pull/56) - -### New Contributors - -* [@leonkozlowski](https://github.com/leonkozlowski) made their first contribution in [#67](https://github.com/PrefectHQ/fastmcp/pull/67) - -**Full Changelog**: [v0.4.0...v0.4.1](https://github.com/PrefectHQ/fastmcp/compare/v0.4.0...v0.4.1) -</Update> - -<Update label="v0.4.0" description="2024-12-05"> - -## [v0.4.0: Nice to MIT You](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.4.0) - -This is a relatively small release in terms of features, but the version is bumped to 0.4 to reflect that the code is being relicensed from Apache 2.0 to MIT. This is to facilitate FastMCP's inclusion in the official MCP SDK. - -### New Features 🎉 - -* Add pyright + tests by [@jlowin](https://github.com/jlowin) in [#52](https://github.com/PrefectHQ/fastmcp/pull/52) -* add pgvector memory example by [@zzstoatzz](https://github.com/zzstoatzz) in [#49](https://github.com/PrefectHQ/fastmcp/pull/49) - -### Fixes 🐞 - -* fix: use stderr for logging by [@sd2k](https://github.com/sd2k) in [#51](https://github.com/PrefectHQ/fastmcp/pull/51) - -### Docs 📚 - -* Update ai-labeler.yml by [@jlowin](https://github.com/jlowin) in [#48](https://github.com/PrefectHQ/fastmcp/pull/48) -* Relicense from Apache 2.0 to MIT by [@jlowin](https://github.com/jlowin) in [#54](https://github.com/PrefectHQ/fastmcp/pull/54) - -### Other Changes 🦾 - -* fix warning and flake by [@zzstoatzz](https://github.com/zzstoatzz) in [#47](https://github.com/PrefectHQ/fastmcp/pull/47) - -### New Contributors - -* [@sd2k](https://github.com/sd2k) made their first contribution in [#51](https://github.com/PrefectHQ/fastmcp/pull/51) - -**Full Changelog**: [v0.3.5...v0.4.0](https://github.com/PrefectHQ/fastmcp/compare/v0.3.5...v0.4.0) -</Update> - -<Update label="v0.3.5" description="2024-12-03"> - -## [v0.3.5: Windows of Opportunity](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.5) - -This release is highlighted by the ability to handle complex JSON objects as MCP inputs and improved Windows compatibility. - -### New Features 🎉 - -* Set up multiple os tests by [@jlowin](https://github.com/jlowin) in [#44](https://github.com/PrefectHQ/fastmcp/pull/44) -* Changes to accommodate windows users. by [@justjoehere](https://github.com/justjoehere) in [#42](https://github.com/PrefectHQ/fastmcp/pull/42) -* Handle complex inputs by [@jurasofish](https://github.com/jurasofish) in [#31](https://github.com/PrefectHQ/fastmcp/pull/31) - -### Docs 📚 - -* Make AI labeler more conservative by [@jlowin](https://github.com/jlowin) in [#46](https://github.com/PrefectHQ/fastmcp/pull/46) - -### Other Changes 🦾 - -* Additional Windows Fixes for Dev running and for importing modules in a server by [@justjoehere](https://github.com/justjoehere) in [#43](https://github.com/PrefectHQ/fastmcp/pull/43) - -### New Contributors - -* [@justjoehere](https://github.com/justjoehere) made their first contribution in [#42](https://github.com/PrefectHQ/fastmcp/pull/42) -* [@jurasofish](https://github.com/jurasofish) made their first contribution in [#31](https://github.com/PrefectHQ/fastmcp/pull/31) - -**Full Changelog**: [v0.3.4...v0.3.5](https://github.com/PrefectHQ/fastmcp/compare/v0.3.4...v0.3.5) -</Update> - -<Update label="v0.3.4" description="2024-12-02"> - -## [v0.3.4: URL's Well That Ends Well](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.4) - -### Fixes 🐞 - -* Handle missing config file when installing by [@jlowin](https://github.com/jlowin) in [#37](https://github.com/PrefectHQ/fastmcp/pull/37) -* Remove BaseURL reference and use AnyURL by [@jlowin](https://github.com/jlowin) in [#40](https://github.com/PrefectHQ/fastmcp/pull/40) - -**Full Changelog**: [v0.3.3...v0.3.4](https://github.com/PrefectHQ/fastmcp/compare/v0.3.3...v0.3.4) -</Update> - -<Update label="v0.3.3" description="2024-12-02"> - -## [v0.3.3: Dependence Day](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.3) - -### New Features 🎉 - -* Surge example by [@zzstoatzz](https://github.com/zzstoatzz) in [#29](https://github.com/PrefectHQ/fastmcp/pull/29) -* Support Python dependencies in Server by [@jlowin](https://github.com/jlowin) in [#34](https://github.com/PrefectHQ/fastmcp/pull/34) - -### Docs 📚 - -* add `Contributing` section to README by [@zzstoatzz](https://github.com/zzstoatzz) in [#32](https://github.com/PrefectHQ/fastmcp/pull/32) - -**Full Changelog**: [v0.3.2...v0.3.3](https://github.com/PrefectHQ/fastmcp/compare/v0.3.2...v0.3.3) -</Update> - -<Update label="v0.3.2" date="2024-12-01" description="Green with ENVy"> - -## [v0.3.2: Green with ENVy](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.2) - -### New Features 🎉 - -* Support env vars when installing by [@jlowin](https://github.com/jlowin) in [#27](https://github.com/PrefectHQ/fastmcp/pull/27) - -### Docs 📚 - -* Remove top level env var by [@jlowin](https://github.com/jlowin) in [#28](https://github.com/PrefectHQ/fastmcp/pull/28) - -**Full Changelog**: [v0.3.1...v0.3.2](https://github.com/PrefectHQ/fastmcp/compare/v0.3.1...v0.3.2) -</Update> - -<Update label="v0.3.1" description="2024-12-01"> - -## [v0.3.1](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.1) - -### New Features 🎉 - -* Update README.md by [@jlowin](https://github.com/jlowin) in [#23](https://github.com/PrefectHQ/fastmcp/pull/23) -* add rich handler and dotenv loading for settings by [@zzstoatzz](https://github.com/zzstoatzz) in [#22](https://github.com/PrefectHQ/fastmcp/pull/22) -* print exception when server can't start by [@jlowin](https://github.com/jlowin) in [#25](https://github.com/PrefectHQ/fastmcp/pull/25) - -### Docs 📚 - -* Update README.md by [@jlowin](https://github.com/jlowin) in [#24](https://github.com/PrefectHQ/fastmcp/pull/24) - -### Other Changes 🦾 - -* Remove log by [@jlowin](https://github.com/jlowin) in [#26](https://github.com/PrefectHQ/fastmcp/pull/26) - -**Full Changelog**: [v0.3.0...v0.3.1](https://github.com/PrefectHQ/fastmcp/compare/v0.3.0...v0.3.1) -</Update> - -<Update label="v0.3.0" description="2024-12-01"> - -## [v0.3.0: Prompt and Circumstance](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.0) - -### New Features 🎉 - -* Update README by [@jlowin](https://github.com/jlowin) in [#3](https://github.com/PrefectHQ/fastmcp/pull/3) -* Make log levels strings by [@jlowin](https://github.com/jlowin) in [#4](https://github.com/PrefectHQ/fastmcp/pull/4) -* Make content method a function by [@jlowin](https://github.com/jlowin) in [#5](https://github.com/PrefectHQ/fastmcp/pull/5) -* Add template support by [@jlowin](https://github.com/jlowin) in [#6](https://github.com/PrefectHQ/fastmcp/pull/6) -* Refactor resources module by [@jlowin](https://github.com/jlowin) in [#7](https://github.com/PrefectHQ/fastmcp/pull/7) -* Clean up cli imports by [@jlowin](https://github.com/jlowin) in [#8](https://github.com/PrefectHQ/fastmcp/pull/8) -* Prepare to list templates by [@jlowin](https://github.com/jlowin) in [#11](https://github.com/PrefectHQ/fastmcp/pull/11) -* Move image to separate module by [@jlowin](https://github.com/jlowin) in [#9](https://github.com/PrefectHQ/fastmcp/pull/9) -* Add support for request context, progress, logging, etc. by [@jlowin](https://github.com/jlowin) in [#12](https://github.com/PrefectHQ/fastmcp/pull/12) -* Add context tests and better runtime loads by [@jlowin](https://github.com/jlowin) in [#13](https://github.com/PrefectHQ/fastmcp/pull/13) -* Refactor tools + resourcemanager by [@jlowin](https://github.com/jlowin) in [#14](https://github.com/PrefectHQ/fastmcp/pull/14) -* func → fn everywhere by [@jlowin](https://github.com/jlowin) in [#15](https://github.com/PrefectHQ/fastmcp/pull/15) -* Add support for prompts by [@jlowin](https://github.com/jlowin) in [#16](https://github.com/PrefectHQ/fastmcp/pull/16) -* Create LICENSE by [@jlowin](https://github.com/jlowin) in [#18](https://github.com/PrefectHQ/fastmcp/pull/18) -* Update cli file spec by [@jlowin](https://github.com/jlowin) in [#19](https://github.com/PrefectHQ/fastmcp/pull/19) -* Update readmeUpdate README by [@jlowin](https://github.com/jlowin) in [#20](https://github.com/PrefectHQ/fastmcp/pull/20) -* Use hatchling for version by [@jlowin](https://github.com/jlowin) in [#21](https://github.com/PrefectHQ/fastmcp/pull/21) - -### Other Changes 🦾 - -* Add echo server by [@jlowin](https://github.com/jlowin) in [#1](https://github.com/PrefectHQ/fastmcp/pull/1) -* Add github workflows by [@jlowin](https://github.com/jlowin) in [#2](https://github.com/PrefectHQ/fastmcp/pull/2) -* typing updates by [@zzstoatzz](https://github.com/zzstoatzz) in [#17](https://github.com/PrefectHQ/fastmcp/pull/17) - -### New Contributors - -* [@jlowin](https://github.com/jlowin) made their first contribution in [#1](https://github.com/PrefectHQ/fastmcp/pull/1) -* [@zzstoatzz](https://github.com/zzstoatzz) made their first contribution in [#17](https://github.com/PrefectHQ/fastmcp/pull/17) - -**Full Changelog**: [v0.2.0...v0.3.0](https://github.com/PrefectHQ/fastmcp/compare/v0.2.0...v0.3.0) -</Update> - -<Update label="v0.2.0" description="2024-11-30"> - -## [v0.2.0](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.2.0) - -**Full Changelog**: [v0.1.0...v0.2.0](https://github.com/PrefectHQ/fastmcp/compare/v0.1.0...v0.2.0) -</Update> - -<Update label="v0.1.0" description="2024-11-30"> - -## [v0.1.0](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.1.0) - -The very first release of FastMCP! 🎉 - -**Full Changelog**: [Initial commits](https://github.com/PrefectHQ/fastmcp/commits/v0.1.0) -</Update> diff --git a/docs/v3/cli/auth.mdx b/docs/v3/cli/auth.mdx deleted file mode 100644 index 71b89e08a..000000000 --- a/docs/v3/cli/auth.mdx +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Auth Utilities -sidebarTitle: Auth -description: Create and validate CIMD documents for OAuth -icon: key ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -The `fastmcp auth` commands help with CIMD (Client ID Metadata Document) management — part of MCP's OAuth authentication flow. A CIMD is a JSON document you host at an HTTPS URL to identify your client application to MCP servers. - -## Creating a CIMD - -`fastmcp auth cimd create` generates a CIMD document: - -```bash -fastmcp auth cimd create \ - --name "My App" \ - --redirect-uri "http://localhost:*/callback" -``` - -```json -{ - "client_id": "https://your-domain.com/oauth/client.json", - "client_name": "My App", - "redirect_uris": ["http://localhost:*/callback"], - "token_endpoint_auth_method": "none" -} -``` - -The generated document includes a placeholder `client_id` — update it to match the URL where you'll host the document before deploying. - -### Options - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| Name | `--name` | **Required.** Human-readable client name | -| Redirect URI | `--redirect-uri` | **Required.** Allowed redirect URIs (repeatable) | -| Client URI | `--client-uri` | Client's home page URL | -| Logo URI | `--logo-uri` | Client's logo URL | -| Scope | `--scope` | Space-separated list of scopes | -| Output | `--output`, `-o` | Save to file (default: stdout) | -| Pretty | `--pretty` | Pretty-print JSON (default: true) | - -### Example - -```bash -fastmcp auth cimd create \ - --name "My Production App" \ - --redirect-uri "http://localhost:*/callback" \ - --redirect-uri "https://myapp.example.com/callback" \ - --client-uri "https://myapp.example.com" \ - --scope "read write" \ - --output client.json -``` - -## Validating a CIMD - -`fastmcp auth cimd validate` fetches a hosted CIMD and verifies it conforms to the spec: - -```bash -fastmcp auth cimd validate https://myapp.example.com/oauth/client.json -``` - -The validator checks that the URL is valid (HTTPS, non-root path), the document is valid JSON, the `client_id` matches the URL, and no shared-secret auth methods are used. - -On success: - -``` -→ Fetching https://myapp.example.com/oauth/client.json... -✓ Valid CIMD document - -Document details: - client_id: https://myapp.example.com/oauth/client.json - client_name: My App - token_endpoint_auth_method: none - redirect_uris: - • http://localhost:*/callback -``` - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| Timeout | `--timeout`, `-t` | HTTP request timeout in seconds (default: 10) | diff --git a/docs/v3/cli/client.mdx b/docs/v3/cli/client.mdx deleted file mode 100644 index b5ef1d4e8..000000000 --- a/docs/v3/cli/client.mdx +++ /dev/null @@ -1,144 +0,0 @@ ---- -title: Client Commands -sidebarTitle: Client -description: List tools, call them, and discover configured servers -icon: satellite-dish ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -The CLI can act as an MCP client — connecting to any server (local or remote) to list what it exposes and call its tools directly. This is useful for development, debugging, scripting, and giving shell-capable LLM agents access to MCP servers. - -## Listing Tools - -`fastmcp list` connects to a server and prints its tools as function signatures, showing parameter names, types, and descriptions at a glance: - -```bash -fastmcp list http://localhost:8000/mcp -fastmcp list server.py -fastmcp list weather # name-based resolution -``` - -When you need the full JSON Schema for a tool's inputs or outputs — for understanding nested objects, enum constraints, or complex types — opt in with `--input-schema` or `--output-schema`: - -```bash -fastmcp list server.py --input-schema -``` - -### Resources and Prompts - -By default, only tools are shown. Add `--resources` or `--prompts` to include those: - -```bash -fastmcp list server.py --resources --prompts -``` - -### Machine-Readable Output - -The `--json` flag switches to structured JSON with full schemas included. This is the format to use when feeding tool definitions to an LLM or building automation: - -```bash -fastmcp list server.py --json -``` - -### Options - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| Command | `--command` | Connect via stdio (e.g., `'npx -y @mcp/server'`) | -| Transport | `--transport`, `-t` | Force `http` or `sse` for URL targets | -| Resources | `--resources` | Include resources in output | -| Prompts | `--prompts` | Include prompts in output | -| Input Schema | `--input-schema` | Show full input schemas | -| Output Schema | `--output-schema` | Show full output schemas | -| JSON | `--json` | Structured JSON output | -| Timeout | `--timeout` | Connection timeout in seconds | -| Auth | `--auth` | `oauth` (default for HTTP), a bearer token, or `none` | - -## Calling Tools - -`fastmcp call` invokes a single tool on a server. Pass arguments as `key=value` pairs — the CLI fetches the tool's schema and coerces your string values to the right types automatically: - -```bash -fastmcp call server.py greet name=World -fastmcp call http://localhost:8000/mcp search query=hello limit=5 -``` - -Type coercion is schema-driven: `"5"` becomes the integer `5` when the schema expects an integer. Booleans accept `true`/`false`, `yes`/`no`, and `1`/`0`. Arrays and objects are parsed as JSON. - -### Complex Arguments - -For tools with nested or structured parameters, `key=value` syntax gets awkward. Pass a single JSON object instead: - -```bash -fastmcp call server.py create_item '{"name": "Widget", "tags": ["sale"], "metadata": {"color": "blue"}}' -``` - -Or use `--input-json` to provide a base dictionary, then override individual keys with `key=value` pairs: - -```bash -fastmcp call server.py search --input-json '{"query": "hello", "limit": 5}' limit=10 -``` - -### Error Handling - -If you misspell a tool name, the CLI suggests corrections via fuzzy matching. Missing required arguments produce a clear message with the tool's signature as a reminder. Tool execution errors are printed with a non-zero exit code, making the CLI straightforward to use in scripts. - -### Structured Output - -`--json` emits the raw result including content blocks, error status, and structured content: - -```bash -fastmcp call server.py get_weather city=London --json -``` - -### Interactive Elicitation - -Some tools request additional input during execution through MCP's elicitation mechanism. When this happens, the CLI prompts you in the terminal — showing each field's name, type, and whether it's required. You can type `decline` to skip a question or `cancel` to abort the call entirely. - -### Options - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| Command | `--command` | Connect via stdio | -| Transport | `--transport`, `-t` | Force `http` or `sse` | -| Input JSON | `--input-json` | Base arguments as JSON (merged with `key=value`) | -| JSON | `--json` | Raw JSON output | -| Timeout | `--timeout` | Connection timeout in seconds | -| Auth | `--auth` | `oauth`, a bearer token, or `none` | - -## Discovering Configured Servers - -`fastmcp discover` scans your machine for MCP servers configured in editors and tools. It checks: - -- **Claude Desktop** — `claude_desktop_config.json` -- **Claude Code** — `~/.claude.json` -- **Cursor** — `.cursor/mcp.json` (walks up from current directory) -- **Gemini CLI** — `~/.gemini/settings.json` -- **Goose** — `~/.config/goose/config.yaml` -- **Project** — `./mcp.json` in the current directory - -```bash -fastmcp discover -``` - -The output groups servers by source, showing each server's name and transport. Filter by source or get machine-readable output: - -```bash -fastmcp discover --source claude-code -fastmcp discover --source cursor --source gemini --json -``` - -Any server that appears here can be used by name with `list`, `call`, and other commands — so you can go from "I have a server in Claude Code" to querying it without copying URLs or paths. - -## LLM Agent Integration - -For LLM agents that can execute shell commands but don't have native MCP support, the CLI provides a clean bridge. The agent calls `fastmcp list --json` to discover available tools with full schemas, then `fastmcp call --json` to invoke them with structured results. - -Because the CLI handles connection management, transport selection, and type coercion internally, the agent doesn't need to understand MCP protocol details — it just reads JSON and constructs shell commands. - -## Remote Stdio Bridges - -For MCP hosts that expect a local stdio command but need to connect to a remote HTTP server, use [`fastmcp-remote`](/clients/fastmcp-remote). It provides a small standalone bridge for host configuration, while `fastmcp list` and `fastmcp call` remain focused on direct inspection and invocation from the terminal. diff --git a/docs/v3/cli/generate-cli.mdx b/docs/v3/cli/generate-cli.mdx deleted file mode 100644 index 2754d199a..000000000 --- a/docs/v3/cli/generate-cli.mdx +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: Generate CLI -sidebarTitle: Generate CLI -description: Scaffold a standalone typed CLI from any MCP server -icon: wand-magic-sparkles ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -`fastmcp list` and `fastmcp call` are general-purpose — you always specify the server, the tool name, and the arguments from scratch. `fastmcp generate-cli` goes further: it connects to a server, reads its tool schemas, and writes a standalone Python script where every tool is a proper subcommand with typed flags, help text, and tab completion. The result is a CLI that feels hand-written for that specific server. - -MCP tool schemas already contain everything a CLI framework needs — parameter names, types, descriptions, required/optional status, and defaults. `generate-cli` maps that into [cyclopts](https://cyclopts.readthedocs.io/) commands, so JSON Schema types become Python type annotations, descriptions become `--help` text, and required parameters become mandatory flags. - -## Generating a Script - -Point the command at any [server target](/cli/overview#server-targets) and it writes a CLI script: - -```bash -fastmcp generate-cli weather -fastmcp generate-cli http://localhost:8000/mcp -fastmcp generate-cli server.py my_weather_cli.py -``` - -The second positional argument sets the output path (defaults to `cli.py`). If the file already exists, pass `-f` to overwrite: - -```bash -fastmcp generate-cli weather -f -``` - -## What You Get - -The generated script is a regular Python file — executable, editable, and yours: - -``` -$ python cli.py call-tool --help -Usage: weather-cli call-tool COMMAND - -Call a tool on the server - -Commands: - get_forecast Get the weather forecast for a city. - search_city Search for a city by name. -``` - -Each tool has typed parameters with help text pulled directly from the server's schema: - -``` -$ python cli.py call-tool get_forecast --help -Usage: weather-cli call-tool get_forecast [OPTIONS] - -Get the weather forecast for a city. - -Options: - --city [str] City name (required) - --days [int] Number of forecast days (default: 3) -``` - -Beyond tool commands, the script includes generic MCP operations — `list-tools`, `list-resources`, `read-resource`, `list-prompts`, and `get-prompt` — that always reflect the server's current state, even if tools have changed since generation. - -## Parameter Handling - -Parameters are mapped based on their JSON Schema type: - -**Simple types** (`string`, `integer`, `number`, `boolean`) become typed flags: - -```bash -python cli.py call-tool get_forecast --city London --days 3 -``` - -**Arrays of simple types** become repeatable flags: - -```bash -python cli.py call-tool tag_items --tags python --tags fastapi --tags mcp -``` - -**Complex types** (objects, nested arrays, unions) accept JSON strings. The `--help` output shows the full schema so you know what structure to pass: - -```bash -python cli.py call-tool create_user \ - --name John \ - --metadata '{"role": "admin", "dept": "engineering"}' -``` - -## Agent Skill - -Alongside the CLI script, `generate-cli` writes a `SKILL.md` file — a [Claude Code agent skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) that documents every tool's exact invocation syntax, parameter flags, types, and descriptions. An agent can pick up the CLI immediately without running `--help` or experimenting with flag names. - -To skip skill generation: - -```bash -fastmcp generate-cli weather --no-skill -``` - -## How It Works - -The generated script is a *client*, not a server — it connects to the server on every invocation rather than bundling it. A `CLIENT_SPEC` variable at the top holds the resolved transport (a URL string or `StdioTransport` with baked-in command and arguments). - -The most common edit is changing `CLIENT_SPEC` — for example, pointing a script generated from a dev server at production. Beyond that, the helper functions (`_call_tool`, `_print_tool_result`) are thin wrappers around `fastmcp.Client` that are easy to adapt. - -The script requires `fastmcp` as a dependency. If it lives outside a project that already has FastMCP installed: - -```bash -uv run --with fastmcp python cli.py call-tool get_forecast --city London -``` diff --git a/docs/v3/cli/inspecting.mdx b/docs/v3/cli/inspecting.mdx deleted file mode 100644 index 657921357..000000000 --- a/docs/v3/cli/inspecting.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: Inspecting Servers -sidebarTitle: Inspecting -description: View a server's components and metadata -icon: magnifying-glass ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.9.0" /> - -`fastmcp inspect` loads a server and reports what it contains — its tools, resources, prompts, version, and metadata. The default output is a human-readable summary: - -```bash -fastmcp inspect server.py -``` - -``` -Server: MyServer -Instructions: A helpful MCP server -Version: 1.0.0 - -Components: - Tools: 5 - Prompts: 2 - Resources: 3 - Templates: 1 - -Environment: - FastMCP: 2.0.0 - MCP: 1.0.0 - -Use --format [fastmcp|mcp] for complete JSON output -``` - -## JSON Output - -For programmatic use, two JSON formats are available: - -**FastMCP format** (`--format fastmcp`) includes everything FastMCP knows about the server — tool tags, enabled status, output schemas, annotations, and custom metadata. Field names use `snake_case`. This is the format for debugging and introspecting FastMCP servers. - -**MCP protocol format** (`--format mcp`) shows exactly what MCP clients see through the protocol — only standard MCP fields, `camelCase` names, no FastMCP-specific extensions. This is the format for verifying client compatibility and debugging what clients actually receive. - -```bash -# Full FastMCP metadata to stdout -fastmcp inspect server.py --format fastmcp - -# MCP protocol view saved to file -fastmcp inspect server.py --format mcp -o manifest.json -``` - -## Options - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| Format | `--format`, `-f` | `fastmcp` or `mcp` (required when using `-o`) | -| Output File | `--output`, `-o` | Save to file instead of stdout | - -## Entrypoints - -The `inspect` command supports the same local entrypoints as [`fastmcp run`](/cli/running): inferred instances, explicit entrypoints, factory functions, and `fastmcp.json` configs. - -```bash -fastmcp inspect server.py # inferred instance -fastmcp inspect server.py:my_server # explicit entrypoint -fastmcp inspect server.py:create_server # factory function -fastmcp inspect fastmcp.json # config file -``` - -<Warning> -`inspect` only works with local files and `fastmcp.json` — it doesn't connect to remote URLs or standard MCP config files. -</Warning> diff --git a/docs/v3/cli/install-mcp.mdx b/docs/v3/cli/install-mcp.mdx deleted file mode 100644 index 0171b7854..000000000 --- a/docs/v3/cli/install-mcp.mdx +++ /dev/null @@ -1,146 +0,0 @@ ---- -title: Install MCP Servers -sidebarTitle: Install MCPs -description: Install MCP servers into Claude, Cursor, Gemini, and other clients -icon: download ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.10.3" /> - -`fastmcp install` registers a server with an MCP client application so the client can launch it automatically. Each MCP client runs servers in its own isolated environment, which means dependencies need to be explicitly declared — you can't rely on whatever happens to be installed locally. - -```bash -fastmcp install claude-desktop server.py -fastmcp install claude-code server.py --with pandas --with matplotlib -fastmcp install cursor server.py -e . -``` - -<Warning> -`uv` must be installed and available in your system PATH. Both Claude Desktop and Cursor run servers in isolated environments managed by `uv`. On macOS, install it globally with Homebrew for Claude Desktop compatibility: `brew install uv`. -</Warning> - -## Supported Clients - -| Client | Install method | -| ------ | -------------- | -| `claude-code` | Claude Code's built-in MCP management | -| `claude-desktop` | Direct config file modification | -| `cursor` | Deeplink that opens Cursor for confirmation | -| `gemini-cli` | Gemini CLI's built-in MCP management | -| `goose` | Deeplink that opens Goose for confirmation (uses `uvx`) | -| `mcp-json` | Generates standard MCP JSON config for manual use | -| `stdio` | Outputs the shell command to run via stdio | - -## Declaring Dependencies - -Because MCP clients run servers in isolation, you need to tell the install command what your server needs. There are two approaches: - -**Command-line flags** let you specify dependencies directly: - -```bash -fastmcp install claude-desktop server.py --with pandas --with "sqlalchemy>=2.0" -fastmcp install cursor server.py -e . --with-requirements requirements.txt -``` - -**`fastmcp.json`** configuration files declare dependencies alongside the server definition. When you install from a config file, dependencies are picked up automatically: - -```bash -fastmcp install claude-desktop fastmcp.json -fastmcp install claude-desktop # auto-detects fastmcp.json in current directory -``` - -See [Server Configuration](/deployment/server-configuration) for the full config format. - -## Options - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| Server Name | `--server-name`, `-n` | Custom name for the server | -| Editable Package | `--with-editable`, `-e` | Install a directory in editable mode | -| Extra Packages | `--with` | Additional packages (repeatable) | -| Environment Variables | `--env` | `KEY=VALUE` pairs (repeatable) | -| Environment File | `--env-file`, `-f` | Load env vars from a `.env` file | -| Python | `--python` | Python version (e.g., `3.11`) | -| Project | `--project` | Run within a uv project directory | -| Requirements | `--with-requirements` | Install from a requirements file | -| Config Path | `--config-path` | Custom path to Claude Desktop config directory (`claude-desktop` only) | - -## Examples - -```bash -# Basic install with auto-detected server instance -fastmcp install claude-desktop server.py - -# Install from fastmcp.json with auto-detection -fastmcp install claude-desktop - -# Explicit entrypoint with dependencies -fastmcp install claude-desktop server.py:my_server \ - --server-name "My Analysis Server" \ - --with pandas - -# With environment variables -fastmcp install claude-code server.py \ - --env API_KEY=secret \ - --env DEBUG=true - -# With env file -fastmcp install cursor server.py --env-file .env - -# Specific Python version and requirements file -fastmcp install claude-desktop server.py \ - --python 3.11 \ - --with-requirements requirements.txt - -# With custom config path (claude-desktop only) -fastmcp install claude-desktop server.py \ - --config-path "C:\Users\username\AppData\Local\Packages\Claude_xyz\LocalCache\Roaming\Claude" -``` - -## Generating MCP JSON - -The `mcp-json` target generates standard MCP configuration JSON instead of installing into a specific client. This is useful for clients that FastMCP doesn't directly support, for CI/CD environments, or for sharing server configs: - -```bash -fastmcp install mcp-json server.py -``` - -The output follows the standard format used by Claude Desktop, Cursor, and other MCP clients: - -```json -{ - "server-name": { - "command": "uv", - "args": ["run", "--with", "fastmcp", "fastmcp", "run", "/path/to/server.py"], - "env": { - "API_KEY": "value" - } - } -} -``` - -Use `--copy` to send it to your clipboard instead of stdout. - -## Generating Stdio Commands - -The `stdio` target outputs the shell command an MCP host would use to start your server over stdio: - -```bash -fastmcp install stdio server.py -# Output: uv run --with fastmcp fastmcp run /absolute/path/to/server.py -``` - -When installing from a `fastmcp.json`, dependencies from the config are included automatically: - -```bash -fastmcp install stdio fastmcp.json -# Output: uv run --with fastmcp --with pillow --with 'qrcode[pil]>=8.0' fastmcp run /path/to/server.py -``` - -Use `--copy` to copy to clipboard. - -<Tip> -`fastmcp install` is designed for local server files with stdio transport. For remote servers running over HTTP, use your client's native configuration — FastMCP's value here is simplifying the complex local setup with `uv`, dependencies, and environment variables. -</Tip> diff --git a/docs/v3/cli/overview.mdx b/docs/v3/cli/overview.mdx deleted file mode 100644 index 54783bef0..000000000 --- a/docs/v3/cli/overview.mdx +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: CLI -sidebarTitle: Overview -description: The fastmcp command-line interface -icon: terminal ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -The `fastmcp` CLI is installed automatically with FastMCP. It's the primary way to run, test, install, and interact with MCP servers from your terminal. - -```bash -fastmcp --help -``` - -## Commands at a Glance - -| Command | What it does | -| ------- | ------------ | -| [`run`](/cli/running) | Run a server (local file, factory function, remote URL, or config file) | -| [`dev apps`](/cli/running#previewing-apps) | Launch a browser-based preview UI for Prefab App tools | -| [`dev inspector`](/cli/running#development-with-the-inspector) | Launch a server inside the MCP Inspector for interactive testing | -| [`install`](/cli/install-mcp) | Install a server into Claude Code, Claude Desktop, Cursor, Gemini CLI, or Goose | -| [`inspect`](/cli/inspecting) | Print a server's tools, resources, and prompts as a summary or JSON report | -| [`list`](/cli/client) | List a server's tools (and optionally resources and prompts) | -| [`call`](/cli/client#calling-tools) | Call a single tool with arguments | -| [`discover`](/cli/client#discovering-configured-servers) | Find MCP servers configured in your editors and tools | -| [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas | -| [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project | -| [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth | -| `version` | Print version info (`--copy` to copy to clipboard) | - -## Server Targets - -Most commands need to know *which server* to talk to. You pass a "server spec" as the first argument, and FastMCP resolves the right transport automatically. - -**URLs** connect to a running HTTP server: - -```bash -fastmcp list http://localhost:8000/mcp -fastmcp call http://localhost:8000/mcp get_forecast city=London -``` - -**Python files** are loaded directly — no `mcp.run()` boilerplate needed. FastMCP finds a server instance named `mcp`, `server`, or `app` in the file, or you can specify one explicitly: - -```bash -fastmcp list server.py -fastmcp run server.py:my_custom_server -``` - -**Config files** work too — both FastMCP's own `fastmcp.json` format and standard MCP config files with an `mcpServers` key: - -```bash -fastmcp run fastmcp.json -fastmcp list mcp-config.json -``` - -**Stdio commands** connect to any MCP server that speaks over standard I/O. Use `--command` instead of a positional argument: - -```bash -fastmcp list --command 'npx -y @modelcontextprotocol/server-github' -``` - -### Name-Based Resolution - -If your servers are already configured in an editor or tool, you can refer to them by name. FastMCP scans configs from Claude Desktop, Claude Code, Cursor, Gemini CLI, and Goose: - -```bash -fastmcp list weather -fastmcp call weather get_forecast city=London -``` - -When the same name appears in multiple configs, use the `source:name` form to be specific: - -```bash -fastmcp list claude-code:my-server -fastmcp call cursor:weather get_forecast city=London -``` - -Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what's available on your machine. - -## Authentication - -When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op. - -To skip authentication entirely — useful for local development servers — pass `--auth none`: - -```bash -fastmcp call http://localhost:8000/mcp my_tool --auth none -``` - -You can also pass a bearer token directly: - -```bash -fastmcp list http://localhost:8000/mcp --auth "Bearer sk-..." -``` - -## Transport Override - -FastMCP defaults to Streamable HTTP for URL targets. If the server only supports Server-Sent Events (SSE), force the older transport: - -```bash -fastmcp list http://localhost:8000 --transport sse -``` diff --git a/docs/v3/cli/running.mdx b/docs/v3/cli/running.mdx deleted file mode 100644 index b0cad0a0b..000000000 --- a/docs/v3/cli/running.mdx +++ /dev/null @@ -1,166 +0,0 @@ ---- -title: Running Servers -sidebarTitle: Running -description: Start, develop, and configure servers from the command line -icon: play ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -## Starting a Server - -`fastmcp run` starts a server. Point it at a Python file, a factory function, a remote URL, or a config file: - -```bash -fastmcp run server.py -fastmcp run server.py:create_server -fastmcp run https://example.com/mcp -fastmcp run fastmcp.json -``` - -By default, the server runs over **stdio** — the transport that MCP clients like Claude Desktop expect. To serve over HTTP instead, specify the transport: - -```bash -fastmcp run server.py --transport http -fastmcp run server.py --transport http --host 0.0.0.0 --port 9000 -``` - -### Entrypoints - -FastMCP supports several ways to locate and start your server: - -**Inferred instance** — FastMCP imports the file and looks for a variable named `mcp`, `server`, or `app`: - -```bash -fastmcp run server.py -``` - -**Explicit instance** — point at a specific variable: - -```bash -fastmcp run server.py:my_server -``` - -**Factory function** — FastMCP calls the function and uses the returned server. Useful when your server needs async setup or configuration that runs before startup: - -```bash -fastmcp run server.py:create_server -``` - -**Remote URL** — starts a local proxy that bridges to a remote server. Handy for local development against a deployed server, or for bridging a remote HTTP server to stdio: - -```bash -fastmcp run https://example.com/mcp -``` - -**FastMCP config** — uses a `fastmcp.json` file that declaratively specifies the server, its dependencies, and deployment settings. When you run `fastmcp run` with no arguments, it auto-detects `fastmcp.json` in the current directory: - -```bash -fastmcp run -fastmcp run my-config.fastmcp.json -``` - -See [Server Configuration](/deployment/server-configuration) for the full `fastmcp.json` format. - -**MCP config** — runs servers defined in a standard MCP configuration file (any `.json` with an `mcpServers` key): - -```bash -fastmcp run mcp.json -``` - -<Warning> -`fastmcp run` completely ignores the `if __name__ == "__main__"` block. Any setup code in that block won't execute. If you need initialization logic to run, use a [factory function](/cli/overview#factory-functions). -</Warning> - -### Options - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| Transport | `--transport`, `-t` | `stdio` (default), `http`, or `sse` | -| Host | `--host` | Bind address for HTTP (default: `127.0.0.1`) | -| Port | `--port`, `-p` | Bind port for HTTP (default: `8000`) | -| Path | `--path` | URL path for HTTP (default: `/mcp/`) | -| Log Level | `--log-level`, `-l` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | -| No Banner | `--no-banner` | Suppress the startup banner | -| Auto-Reload | `--reload` / `--no-reload` | Watch for file changes and restart automatically | -| Reload Dirs | `--reload-dir` | Directories to watch (repeatable) | -| Skip Env | `--skip-env` | Don't set up a uv environment (use when already in one) | -| Python | `--python` | Python version to use (e.g., `3.11`) | -| Extra Packages | `--with` | Additional packages to install (repeatable) | -| Project | `--project` | Run within a specific uv project directory | -| Requirements | `--with-requirements` | Install from a requirements file | - -### Dependency Management - -By default, `fastmcp run` uses your current Python environment directly. When you pass `--python`, `--with`, `--project`, or `--with-requirements`, it switches to running via `uv run` in a subprocess, which handles dependency isolation automatically. - -The `--skip-env` flag is useful when you're already inside an activated venv, a Docker container with pre-installed dependencies, or a uv-managed project — it prevents uv from trying to set up another environment layer. - -## Previewing Apps - -<VersionBadge version="3.2.0" /> - -`fastmcp dev apps` launches a browser-based preview UI for servers with [Prefab App tools](/apps/prefab). It starts your MCP server on one port and a local dev UI on another — giving you a live, interactive picker where you can call app tools and see their rendered output without needing a full MCP host client. - -```bash -fastmcp dev apps server.py -fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 -``` - -The picker auto-generates a form from each tool's input schema. Submit the form and the result opens in a new tab as a rendered Prefab UI. - -Auto-reload is on by default — save a file and the MCP server restarts automatically. - -<Tip> -`fastmcp dev apps` requires `fastmcp[apps]` — install with `pip install "fastmcp[apps]"`. -</Tip> - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| MCP Port | `--mcp-port` | Port for the MCP server (default: `8000`) | -| Dev Port | `--dev-port` | Port for the dev UI (default: `8080`) | -| Auto-Reload | `--reload` / `--no-reload` | Watch for file changes (default: on) | - -## Development with the Inspector - -`fastmcp dev inspector` launches your server inside the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), a browser-based tool for interactively testing MCP servers. Auto-reload is on by default, so your server restarts when you save changes. - -```bash -fastmcp dev inspector server.py -fastmcp dev inspector server.py -e . --with pandas -``` - -<Tip> -The Inspector always runs your server via `uv run` in a subprocess — it never uses your local environment directly. Specify dependencies with `--with`, `--with-editable`, `--with-requirements`, or through a `fastmcp.json` file. -</Tip> - -<Warning> -The Inspector connects over **stdio only**. When it launches, you may need to select "STDIO" from the transport dropdown and click connect. To test a server over HTTP, start it separately with `fastmcp run server.py --transport http` and point the Inspector at the URL. -</Warning> - -| Option | Flag | Description | -| ------ | ---- | ----------- | -| Editable Package | `--with-editable`, `-e` | Install a directory in editable mode | -| Extra Packages | `--with` | Additional packages (repeatable) | -| Inspector Version | `--inspector-version` | MCP Inspector version to use | -| UI Port | `--ui-port` | Port for the Inspector UI | -| Server Port | `--server-port` | Port for the Inspector proxy | -| Auto-Reload | `--reload` / `--no-reload` | File watching (default: on) | -| Reload Dirs | `--reload-dir` | Directories to watch (repeatable) | -| Python | `--python` | Python version | -| Project | `--project` | Run within a uv project directory | -| Requirements | `--with-requirements` | Install from a requirements file | - -## Pre-Building Environments - -`fastmcp project prepare` creates a persistent uv project from a `fastmcp.json` file, pre-installing all dependencies. This separates environment setup from server execution — install once, run many times. - -```bash -# Step 1: Build the environment (slow, does dependency resolution) -fastmcp project prepare fastmcp.json --output-dir ./env - -# Step 2: Run using the prepared environment (fast, no install step) -fastmcp run fastmcp.json --project ./env -``` - -The prepared directory contains a `pyproject.toml`, a `.venv` with all packages installed, and a `uv.lock` for reproducibility. This is particularly useful in deployment scenarios where you want deterministic, pre-built environments. diff --git a/docs/v3/clients/auth/bearer.mdx b/docs/v3/clients/auth/bearer.mdx deleted file mode 100644 index 2e12fbc13..000000000 --- a/docs/v3/clients/auth/bearer.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: Bearer Token Authentication -sidebarTitle: Bearer Auth -description: Authenticate your FastMCP client with a Bearer token. -icon: key ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.6.0" /> - -<Tip> -Bearer Token authentication is only relevant for HTTP-based transports. -</Tip> - -You can configure your FastMCP client to use **bearer authentication** by supplying a valid access token. This is most appropriate for service accounts, long-lived API keys, CI/CD, applications where authentication is managed separately, or other non-interactive authentication methods. - -A Bearer token is a JSON Web Token (JWT) that is used to authenticate a request. It is most commonly used in the `Authorization` header of an HTTP request, using the `Bearer` scheme: - -```http -Authorization: Bearer <token> -``` - - -## Client Usage - -The most straightforward way to use a pre-existing Bearer token is to provide it as a string to the `auth` parameter of the `fastmcp.Client` or transport instance. FastMCP will automatically format it correctly for the `Authorization` header and bearer scheme. - -<Tip> -If you're using a string token, do not include the `Bearer` prefix. FastMCP will add it for you. -</Tip> - -```python {5} -from fastmcp import Client - -async with Client( - "https://your-server.fastmcp.app/mcp", - auth="<your-token>", -) as client: - await client.ping() -``` - -You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`: - -```python {6} -from fastmcp import Client -from fastmcp.client.transports import StreamableHttpTransport - -transport = StreamableHttpTransport( - "http://your-server.fastmcp.app/mcp", - auth="<your-token>", -) - -async with Client(transport) as client: - await client.ping() -``` - -## `BearerAuth` Helper - -If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx.Auth` interface. - -```python {6} -from fastmcp import Client -from fastmcp.client.auth import BearerAuth - -async with Client( - "https://your-server.fastmcp.app/mcp", - auth=BearerAuth(token="<your-token>"), -) as client: - await client.ping() -``` - -## Custom Headers - -If the MCP server expects a custom header or token scheme, you can manually set the client's `headers` instead of using the `auth` parameter by setting them on your transport: - -```python {5} -from fastmcp import Client -from fastmcp.client.transports import StreamableHttpTransport - -async with Client( - transport=StreamableHttpTransport( - "https://your-server.fastmcp.app/mcp", - headers={"X-API-Key": "<your-token>"}, - ), -) as client: - await client.ping() -``` diff --git a/docs/v3/clients/auth/cimd.mdx b/docs/v3/clients/auth/cimd.mdx deleted file mode 100644 index c1f92d1c4..000000000 --- a/docs/v3/clients/auth/cimd.mdx +++ /dev/null @@ -1,138 +0,0 @@ ---- -title: CIMD Authentication -sidebarTitle: CIMD -description: Use Client ID Metadata Documents for verifiable, domain-based client identity. -icon: id-badge -tag: NEW ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="3.0.0" /> - -<Tip> -CIMD authentication is only relevant for HTTP-based transports and requires a server that advertises CIMD support. -</Tip> - -With standard OAuth, your client registers dynamically with every server it connects to, receiving a fresh `client_id` each time. This works, but the server has no way to verify *who* your client actually is — any client can claim any name during registration. - -CIMD (Client ID Metadata Documents) flips this around. You host a small JSON document at an HTTPS URL you control, and that URL becomes your `client_id`. When your client connects to a server, the server fetches your metadata document and can verify your identity through your domain ownership. Users see a verified domain badge in the consent screen instead of an unverified client name. - -## Client Usage - -Pass your CIMD document URL to the `client_metadata_url` parameter of `OAuth`: - -```python -from fastmcp import Client -from fastmcp.client.auth import OAuth - -async with Client( - "https://mcp-server.example.com/mcp", - auth=OAuth( - client_metadata_url="https://myapp.example.com/oauth/client.json", - ), -) as client: - await client.ping() -``` - -When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow. - -<Note> -You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically. -</Note> - -## Creating a CIMD Document - -A CIMD document is a JSON file that describes your client. The most important field is `client_id`, which must exactly match the URL where you host the document. - -Use the FastMCP CLI to generate one: - -```bash -fastmcp auth cimd create \ - --name "My Application" \ - --redirect-uri "http://localhost:*/callback" \ - --client-id "https://myapp.example.com/oauth/client.json" -``` - -This produces: - -```json -{ - "client_id": "https://myapp.example.com/oauth/client.json", - "client_name": "My Application", - "redirect_uris": ["http://localhost:*/callback"], - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code"], - "response_types": ["code"] -} -``` - -If you omit `--client-id`, the CLI generates a placeholder value and reminds you to update it before hosting. - -### CLI Options - -The `create` command accepts these flags: - -| Flag | Description | -|------|-------------| -| `--name` | Human-readable client name (required) | -| `--redirect-uri`, `-r` | Allowed redirect URIs — can be specified multiple times (required) | -| `--client-id` | The URL where you'll host this document (sets `client_id` directly) | -| `--output`, `-o` | Write to a file instead of stdout | -| `--scope` | Space-separated list of scopes the client may request | -| `--client-uri` | URL of the client's home page | -| `--logo-uri` | URL of the client's logo image | -| `--no-pretty` | Output compact JSON | - -### Redirect URIs - -The `redirect_uris` field supports wildcard port matching for localhost. The pattern `http://localhost:*/callback` matches any port, which is useful for development clients that bind to random available ports (which is what FastMCP's `OAuth` helper does by default). - -## Hosting Requirements - -CIMD documents must be hosted at a publicly accessible HTTPS URL with a non-root path: - -- **HTTPS required** — HTTP URLs are rejected for security -- **Non-root path** — The URL must have a path component (e.g., `/oauth/client.json`, not just `/`) -- **Public accessibility** — The server must be able to fetch the document over the internet -- **Matching `client_id`** — The `client_id` field in the document must exactly match the hosting URL - -Common hosting options include static file hosting services like GitHub Pages, Cloudflare Pages, Vercel, or S3 — anywhere you can serve a JSON file over HTTPS. - -## Validating Your Document - -Before deploying, verify your hosted document passes validation: - -```bash -fastmcp auth cimd validate https://myapp.example.com/oauth/client.json -``` - -The validator fetches the document and checks that: -- The URL is valid (HTTPS, non-root path) -- The document is well-formed JSON conforming to the CIMD schema -- The `client_id` in the document matches the URL it was fetched from - -## How It Works - -When your client connects to a CIMD-enabled server, the flow works like this: - -<Steps> -<Step title="Client Presents Metadata URL"> -Your client sends its `client_metadata_url` as the `client_id` in the OAuth authorization request. -</Step> -<Step title="Server Recognizes CIMD URL"> -The server sees that the `client_id` is an HTTPS URL with a path — the signature of a CIMD client — and skips Dynamic Client Registration. -</Step> -<Step title="Server Fetches and Validates"> -The server fetches your JSON document from the URL, validates that `client_id` matches the URL, and extracts your client metadata (name, redirect URIs, scopes). -</Step> -<Step title="Authorization Proceeds"> -The standard OAuth flow continues: browser opens for user consent, authorization code exchange, token issuance. The consent screen shows your verified domain. -</Step> -</Steps> - -The server caches your CIMD document according to HTTP cache headers, so subsequent requests don't require re-fetching. - -## Server Configuration - -CIMD is a server-side feature that your MCP server must support. FastMCP's OAuth proxy providers (GitHub, Google, Auth0, etc.) support CIMD by default. See the [OAuth Proxy CIMD documentation](/servers/auth/oauth-proxy#cimd-support) for server-side configuration, including private key JWT authentication and security details. diff --git a/docs/v3/clients/auth/oauth.mdx b/docs/v3/clients/auth/oauth.mdx deleted file mode 100644 index 84fbe2164..000000000 --- a/docs/v3/clients/auth/oauth.mdx +++ /dev/null @@ -1,186 +0,0 @@ ---- -title: OAuth Authentication -sidebarTitle: OAuth -description: Authenticate your FastMCP client via OAuth 2.1. -icon: window ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.6.0" /> - -<Tip> -OAuth authentication is only relevant for HTTP-based transports and requires user interaction via a web browser. -</Tip> - -When your FastMCP client needs to access an MCP server protected by OAuth 2.1, and the process requires user interaction (like logging in and granting consent), you should use the Authorization Code Flow. FastMCP provides the `fastmcp.client.auth.OAuth` helper to simplify this entire process. - -This flow is common for user-facing applications where the application acts on behalf of the user. - -## Client Usage - - -### Default Configuration - -The simplest way to use OAuth is to pass the string `"oauth"` to the `auth` parameter of the `Client` or transport instance. FastMCP will automatically configure the client to use OAuth with default settings: - -```python {4} -from fastmcp import Client - -# Uses default OAuth settings -async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client: - await client.ping() -``` - - -### `OAuth` Helper - -To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.1 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx.Auth` interface. - -```python {2, 4, 6} -from fastmcp import Client -from fastmcp.client.auth import OAuth - -oauth = OAuth(scopes=["user"]) - -async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: - await client.ping() -``` - -<Note> -You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically. -</Note> - -#### `OAuth` Parameters - -- **`scopes`** (`str | list[str]`, optional): OAuth scopes to request. Can be space-separated string or list of strings -- **`client_name`** (`str`, optional): Client name for dynamic registration. Defaults to `"FastMCP Client"` -- **`client_id`** (`str`, optional): Pre-registered OAuth client ID. When provided, skips Dynamic Client Registration entirely. See [Pre-Registered Clients](#pre-registered-clients) -- **`client_secret`** (`str`, optional): OAuth client secret for pre-registered clients. Optional — public clients that rely on PKCE can omit this -- **`client_metadata_url`** (`str`, optional): URL-based client identity (CIMD). See [CIMD Authentication](/clients/auth/cimd) for details -- **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options -- **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration -- **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port -- **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx clients - - -## OAuth Flow - -The OAuth flow is triggered when you use a FastMCP `Client` configured to use OAuth. - -<Steps> -<Step title="Token Check"> -The client first checks the configured `token_storage` backend for existing, valid tokens for the target server. If one is found, it will be used to authenticate the client. -</Step> -<Step title="OAuth Server Discovery"> -If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`. -</Step> -<Step title="Client Registration"> -If a `client_id` is provided, the client uses those pre-registered credentials directly and skips this step entirely. Otherwise, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity. As a fallback, the client performs Dynamic Client Registration (RFC 7591) if the server supports it. -</Step> -<Step title="Local Callback Server"> -A temporary local HTTP server is started on an available port (or the port specified via `callback_port`). This server's address (e.g., `http://127.0.0.1:<port>/callback`) acts as the `redirect_uri` for the OAuth flow. -</Step> -<Step title="Browser Interaction"> -The user's default web browser is automatically opened, directing them to the OAuth server's authorization endpoint. The user logs in and grants (or denies) the requested `scopes`. -</Step> -<Step title="Authorization Code & Token Exchange"> -Upon approval, the OAuth server redirects the user's browser to the local callback server with an `authorization_code`. The client captures this code and exchanges it with the OAuth server's token endpoint for an `access_token` (and often a `refresh_token`) using PKCE for security. -</Step> -<Step title="Token Caching"> -The obtained tokens are saved to the configured `token_storage` backend for future use, eliminating the need for repeated browser interactions. -</Step> -<Step title="Authenticated Requests"> -The access token is automatically included in the `Authorization` header for requests to the MCP server. -</Step> -<Step title="Refresh Token"> -If the access token expires, the client will automatically use the refresh token to get a new access token. -</Step> -</Steps> - -## Token Storage - -<VersionBadge version="2.13.0" /> - -By default, tokens are stored in memory and lost when your application restarts. For persistent storage, pass an `AsyncKeyValue`-compatible storage backend to the `token_storage` parameter. - -<Warning> -**Security Consideration**: Use encrypted storage for production. MCP clients can accumulate OAuth credentials for many servers over time, and a compromised token store could expose access to multiple services. -</Warning> - -```python -from fastmcp import Client -from fastmcp.client.auth import OAuth -from key_value.aio.stores.disk import DiskStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet -import os - -# Create encrypted disk storage -encrypted_storage = FernetEncryptionWrapper( - key_value=DiskStore(directory="~/.fastmcp/oauth-tokens"), - fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"]) -) - -oauth = OAuth(token_storage=encrypted_storage) - -async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: - await client.ping() -``` - -You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/strawgate/py-key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption. - -<Note> -When selecting a storage backend, review the [py-key-value documentation](https://github.com/strawgate/py-key-value) to understand the maturity level and limitations of your chosen backend. Some backends may be in preview or have constraints that affect production suitability. -</Note> - -## CIMD Authentication - -<VersionBadge version="3.0.0" /> - -Client ID Metadata Documents (CIMD) provide an alternative to Dynamic Client Registration. Instead of registering with each server, your client hosts a static JSON document at an HTTPS URL. That URL becomes your client's identity, and servers can verify who you are through your domain ownership. - -```python -from fastmcp import Client -from fastmcp.client.auth import OAuth - -async with Client( - "https://mcp-server.example.com/mcp", - auth=OAuth( - client_metadata_url="https://myapp.example.com/oauth/client.json", - ), -) as client: - await client.ping() -``` - -See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents. - -## Pre-Registered Clients - -<VersionBadge version="3.0.0" /> - -Some OAuth servers don't support Dynamic Client Registration — the MCP spec explicitly makes DCR optional. If your client has been pre-registered with the server (you already have a `client_id` and optionally a `client_secret`), you can provide them directly to skip DCR entirely. - -```python -from fastmcp import Client -from fastmcp.client.auth import OAuth - -async with Client( - "https://mcp-server.example.com/mcp", - auth=OAuth( - client_id="my-registered-client-id", - client_secret="my-client-secret", - ), -) as client: - await client.ping() -``` - -Public clients that rely on PKCE for security can omit `client_secret`: - -```python -oauth = OAuth(client_id="my-public-client-id") -``` - -<Note> -When using pre-registered credentials, the client will not attempt Dynamic Client Registration. If the server rejects the credentials, the error is surfaced immediately rather than falling back to DCR. -</Note> diff --git a/docs/v3/clients/client-only-package.mdx b/docs/v3/clients/client-only-package.mdx deleted file mode 100644 index 020b2f077..000000000 --- a/docs/v3/clients/client-only-package.mdx +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: Client-Only Package -description: Use FastMCP's client without installing the full server framework. -icon: box ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.3.0" /> - -FastMCP's full `fastmcp` package includes everything needed to build and run MCP servers, apps, proxies, and clients. If you are only embedding an MCP client in another framework, building your own LLM host, or testing MCP servers, you can install the smaller client-only package instead. - -```bash -pip install "fastmcp-slim[client]" -``` - -The client-only package uses the `fastmcp` import namespace: - -```python -from fastmcp import Client - -client = Client("https://example.com/mcp") -``` - -Use `fastmcp-slim[client]` when your code connects to MCP servers but does not define or run FastMCP servers itself. For example, framework authors can depend on `fastmcp-slim[client]` to provide MCP connectivity without requiring users to install the full FastMCP server stack. - -## Supported Usage - -Client-only installs support remote and subprocess transports: - -```python -from fastmcp import Client - -# Remote MCP server -http_client = Client("https://example.com/mcp") - -# Local MCP server over stdio -stdio_client = Client("my_server.py") -``` - -Single-server MCP configuration works as well: - -```python -from fastmcp import Client - -config = { - "mcpServers": { - "weather": { - "url": "https://weather.example.com/mcp" - } - } -} - -client = Client(config) -``` - -Optional sampling handlers are available through the same extras as the full package: - -```bash -pip install "fastmcp-slim[client,openai]" -pip install "fastmcp-slim[client,anthropic]" -pip install "fastmcp-slim[client,gemini]" -``` - -## When to Use the Full Package - -Install `fastmcp` when you need server-side FastMCP features: - -```bash -pip install fastmcp -``` - -The full package remains the default for most users and continues to support the existing import style: - -```python -from fastmcp import Client, FastMCP - -server = FastMCP("Example") -client = Client(server) -``` - -Use the full package for: - -- defining or running FastMCP servers -- in-memory clients connected directly to `FastMCP` server objects -- multi-server MCP configurations -- FastMCP apps, proxies, server auth, middleware, and other server-side features - -The `fastmcp-slim` package is intentionally narrower: it is for client-only consumers who want FastMCP's MCP client behavior without depending on the full framework. diff --git a/docs/v3/clients/client.mdx b/docs/v3/clients/client.mdx deleted file mode 100644 index fc5ddc263..000000000 --- a/docs/v3/clients/client.mdx +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: The FastMCP Client -sidebarTitle: Overview -description: Programmatic client for interacting with MCP servers through a well-typed, Pythonic interface. -icon: user-robot ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.0.0" /> - -The `fastmcp.Client` class provides a programmatic interface for interacting with any MCP server. It handles protocol details and connection management automatically, letting you focus on the operations you want to perform. - -The FastMCP Client is designed for deterministic, controlled interactions rather than autonomous behavior, making it ideal for testing MCP servers during development, building deterministic applications that need reliable MCP interactions, and creating the foundation for agentic or LLM-based clients with structured, type-safe operations. - -<Note> -This is a programmatic client that requires explicit function calls and provides direct control over all MCP operations. Use it as a building block for higher-level systems. -</Note> - -## Creating a Client - -You provide a server source and the client automatically infers the appropriate transport mechanism. - -```python -import asyncio -from fastmcp import Client, FastMCP - -# In-memory server (ideal for testing) -server = FastMCP("TestServer") -client = Client(server) - -# HTTP server -client = Client("https://example.com/mcp") - -# Local Python script -client = Client("my_mcp_server.py") - -async def main(): - async with client: - # Basic server interaction - await client.ping() - - # List available operations - tools = await client.list_tools() - resources = await client.list_resources() - prompts = await client.list_prompts() - - # Execute operations - result = await client.call_tool("example_tool", {"param": "value"}) - print(result) - -asyncio.run(main()) -``` - -All client operations require using the `async with` context manager for proper connection lifecycle management. - -## Choosing a Transport - -The client automatically selects a transport based on what you pass to it, but different transports have different characteristics that matter for your use case. - -**In-memory transport** connects directly to a FastMCP server instance within the same Python process. Use this for testing and development where you want to eliminate subprocess and network complexity. The server shares your process's environment and memory space. - -```python -from fastmcp import Client, FastMCP - -server = FastMCP("TestServer") -client = Client(server) # In-memory, no network or subprocess -``` - -**STDIO transport** launches a server as a subprocess and communicates through stdin/stdout pipes. This is the standard mechanism used by desktop clients like Claude Desktop. The subprocess runs in an isolated environment, so you must explicitly pass any environment variables the server needs. - -```python -from fastmcp import Client - -# Simple inference from file path -client = Client("my_server.py") - -# With explicit environment configuration -client = Client("my_server.py", env={"API_KEY": "secret"}) -``` - -**HTTP transport** connects to servers running as web services. Use this for production deployments where the server runs independently and manages its own lifecycle. - -```python -from fastmcp import Client - -client = Client("https://api.example.com/mcp") -``` - -See [Transports](/clients/transports) for detailed configuration options including authentication headers, session persistence, and multi-server configurations. - -## Configuration-Based Clients - -<VersionBadge version="2.4.0" /> - -Create clients from MCP configuration dictionaries, which can include multiple servers. While there is no official standard for MCP configuration format, FastMCP follows established conventions used by tools like Claude Desktop. - -```python -config = { - "mcpServers": { - "weather": { - "url": "https://weather-api.example.com/mcp" - }, - "assistant": { - "command": "python", - "args": ["./assistant_server.py"] - } - } -} - -client = Client(config) - -async with client: - # Tools are prefixed with server names - weather_data = await client.call_tool("weather_get_forecast", {"city": "London"}) - response = await client.call_tool("assistant_answer_question", {"question": "What's the capital of France?"}) - - # Resources use prefixed URIs - icons = await client.read_resource("weather://weather/icons/sunny") -``` - -## Connection Lifecycle - -The client uses context managers for connection management. When you enter the context, the client establishes a connection and performs an MCP initialization handshake with the server. This handshake exchanges capabilities, server metadata, and instructions. - -```python -from fastmcp import Client, FastMCP - -mcp = FastMCP(name="MyServer", instructions="Use the greet tool to say hello!") - -@mcp.tool -def greet(name: str) -> str: - """Greet a user by name.""" - return f"Hello, {name}!" - -async with Client(mcp) as client: - # Initialization already happened automatically - print(f"Server: {client.initialize_result.serverInfo.name}") - print(f"Instructions: {client.initialize_result.instructions}") - print(f"Capabilities: {client.initialize_result.capabilities.tools}") -``` - -For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually: - -```python -from fastmcp import Client - -client = Client("my_mcp_server.py", auto_initialize=False) - -async with client: - # Connection established, but not initialized yet - print(f"Connected: {client.is_connected()}") - print(f"Initialized: {client.initialize_result is not None}") # False - - # Initialize manually with custom timeout - result = await client.initialize(timeout=10.0) - print(f"Server: {result.serverInfo.name}") - - # Now ready for operations - tools = await client.list_tools() -``` - -## Operations - -FastMCP clients interact with three types of server components. - -**Tools** are server-side functions that the client can execute with arguments. Call them with `call_tool()` and receive structured results. - -```python -async with client: - tools = await client.list_tools() - result = await client.call_tool("multiply", {"a": 5, "b": 3}) - print(result.data) # 15 -``` - -See [Tools](/clients/tools) for detailed documentation including version selection, error handling, and structured output. - -**Resources** are data sources that the client can read, either static or templated. Access them with `read_resource()` using URIs. - -```python -async with client: - resources = await client.list_resources() - content = await client.read_resource("file:///config/settings.json") - print(content[0].text) -``` - -See [Resources](/clients/resources) for detailed documentation including templates and binary content. - -**Prompts** are reusable message templates that can accept arguments. Retrieve rendered prompts with `get_prompt()`. - -```python -async with client: - prompts = await client.list_prompts() - messages = await client.get_prompt("analyze_data", {"data": [1, 2, 3]}) - print(messages.messages) -``` - -See [Prompts](/clients/prompts) for detailed documentation including argument serialization. - -## Callback Handlers - -The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications. - -```python -from fastmcp import Client -from fastmcp.client.logging import LogMessage - -async def log_handler(message: LogMessage): - print(f"Server log: {message.data}") - -async def progress_handler(progress: float, total: float | None, message: str | None): - print(f"Progress: {progress}/{total} - {message}") - -async def sampling_handler(messages, params, context): - # Integrate with your LLM service here - return "Generated response" - -client = Client( - "my_mcp_server.py", - log_handler=log_handler, - progress_handler=progress_handler, - sampling_handler=sampling_handler, - timeout=30.0 -) -``` - -Each handler type has its own documentation: - -- **[Sampling](/clients/sampling)** - Respond to server LLM requests -- **[Elicitation](/clients/elicitation)** - Handle server requests for user input -- **[Progress](/clients/progress)** - Monitor long-running operations -- **[Logging](/clients/logging)** - Handle server log messages -- **[Roots](/clients/roots)** - Provide local context to servers - -<Tip> -The FastMCP Client is designed as a foundational tool. Use it directly for deterministic operations, or build higher-level agentic systems on top of its reliable, type-safe interface. -</Tip> diff --git a/docs/v3/clients/elicitation.mdx b/docs/v3/clients/elicitation.mdx deleted file mode 100644 index 33adbb6d6..000000000 --- a/docs/v3/clients/elicitation.mdx +++ /dev/null @@ -1,138 +0,0 @@ ---- -title: User Elicitation -sidebarTitle: Elicitation -description: Handle server requests for structured user input. -icon: message-question ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<VersionBadge version="2.10.0" /> - -Use this when you need to respond to server requests for user input during tool execution. - -Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context. - -## Handler Template - -```python -from fastmcp import Client -from fastmcp.client.elicitation import ElicitResult, ElicitRequestParams, RequestContext - -async def elicitation_handler( - message: str, - response_type: type | None, - params: ElicitRequestParams, - context: RequestContext -) -> ElicitResult | object: - """ - Handle server requests for user input. - - Args: - message: The prompt to display to the user - response_type: Python dataclass type for the response (None if no data expected) - params: Original MCP elicitation parameters including raw JSON schema - context: Request context with metadata - - Returns: - - Data directly (implicitly accepts the elicitation) - - ElicitResult for explicit control over the action - """ - # Present the message and collect input - user_input = input(f"{message}: ") - - if not user_input: - return ElicitResult(action="decline") - - # Create response using the provided dataclass type - return response_type(value=user_input) - -client = Client( - "my_mcp_server.py", - elicitation_handler=elicitation_handler, -) -``` - -## How It Works - -When a server needs user input, it sends an elicitation request with a message prompt and a JSON schema describing the expected response structure. FastMCP automatically converts this schema into a Python dataclass type, making it easy to construct properly typed responses without manually parsing JSON schemas. - -The handler receives four parameters: - -<Card icon="code" title="Handler Parameters"> -<ResponseField name="message" type="str"> - The prompt message to display to the user -</ResponseField> - -<ResponseField name="response_type" type="type | None"> - A Python dataclass type that FastMCP created from the server's JSON schema. Use this to construct your response with proper typing. If the server requests an empty object, this will be `None`. -</ResponseField> - -<ResponseField name="params" type="ElicitRequestParams"> - The original MCP elicitation parameters, including the raw JSON schema in `params.requestedSchema` -</ResponseField> - -<ResponseField name="context" type="RequestContext"> - Request context containing metadata about the elicitation request -</ResponseField> -</Card> - -## Response Actions - -You can return data directly, which implicitly accepts the elicitation: - -```python -async def elicitation_handler(message, response_type, params, context): - user_input = input(f"{message}: ") - return response_type(value=user_input) # Implicit accept -``` - -Or return an `ElicitResult` for explicit control over the action: - -```python -from fastmcp.client.elicitation import ElicitResult - -async def elicitation_handler(message, response_type, params, context): - user_input = input(f"{message}: ") - - if not user_input: - return ElicitResult(action="decline") # User declined - - if user_input == "cancel": - return ElicitResult(action="cancel") # Cancel entire operation - - return ElicitResult( - action="accept", - content=response_type(value=user_input) - ) -``` - -**Action types:** -- **`accept`**: User provided valid input. Include the data in the `content` field. -- **`decline`**: User chose not to provide the requested information. Omit `content`. -- **`cancel`**: User cancelled the entire operation. Omit `content`. - -## Example - -A file management tool might ask which directory to create: - -```python -from fastmcp import Client -from fastmcp.client.elicitation import ElicitResult - -async def elicitation_handler(message, response_type, params, context): - print(f"Server asks: {message}") - - user_response = input("Your response: ") - - if not user_response: - return ElicitResult(action="decline") - - # Use the response_type dataclass to create a properly structured response - return response_type(value=user_response) - -client = Client( - "my_mcp_server.py", - elicitation_handler=elicitation_handler -) -``` diff --git a/docs/v3/clients/fastmcp-remote.mdx b/docs/v3/clients/fastmcp-remote.mdx deleted file mode 100644 index ee218afe0..000000000 --- a/docs/v3/clients/fastmcp-remote.mdx +++ /dev/null @@ -1,169 +0,0 @@ ---- -title: fastmcp-remote -description: Bridge remote MCP servers into stdio-only MCP hosts with uvx fastmcp-remote. -icon: bridge ---- - -`fastmcp-remote` is FastMCP's standalone stdio bridge for remote MCP servers. Use it when an MCP host expects to launch a local command, but the server you want to use is hosted over Streamable HTTP or SSE. - -```json -{ - "mcpServers": { - "linear": { - "command": "uvx", - "args": ["fastmcp-remote", "https://mcp.linear.app/mcp"] - } - } -} -``` - -The package is powered by FastMCP. It builds one FastMCP client for the remote URL, exposes that client as a local stdio proxy, and keeps the executable focused on that bridge. For running Python server files, local project environments, FastMCP config files, and development reload loops, use [`fastmcp run`](/cli/running). - -The command shape follows the original [`mcp-remote`](https://github.com/geelen/mcp-remote) npm project, which established this stdio-to-remote bridge pattern for MCP hosts. - -## Installation - -Most MCP hosts can run `fastmcp-remote` directly through `uvx`, so you usually do not need to install it yourself: - -```bash -uvx fastmcp-remote https://example.com/mcp -``` - -If your host requires an already-installed command, install the package with your Python package manager: - -```bash -uv tool install fastmcp-remote -``` - -## Host Configuration - -For hosts that use `mcpServers` JSON configuration, set the command to `uvx` and pass `fastmcp-remote` plus the remote server URL as arguments: - -```json -{ - "mcpServers": { - "remote-api": { - "command": "uvx", - "args": ["fastmcp-remote", "https://example.com/mcp"] - } - } -} -``` - -## Endpoint URLs and Connection Status - -Pass the full MCP endpoint URL for the remote server. Many FastMCP HTTP servers expose MCP at `/mcp`, so a local development server may need `http://localhost:8000/mcp` rather than `http://localhost:8000`. - -`fastmcp-remote` starts a local stdio bridge, then connects to the upstream server when the MCP host initializes that bridge. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or authentication cannot complete, initialization fails and the host should report the remote server as failed. After initialization succeeds, later tool, resource, prompt, and ping requests continue to proxy through the same remote server configuration. - -OAuth is enabled automatically for HTTPS servers. The first connection opens the browser-based OAuth flow when the server requires authentication, then stores tokens locally for future runs. - -To pass a bearer token or another custom header directly, provide `--header` in `Name: Value` form. The header name ends at the first colon, so values can contain additional colons. Quote the header when the value contains spaces, just like any other shell argument. An `Authorization` header disables OAuth by default: - -```json -{ - "mcpServers": { - "private-api": { - "command": "uvx", - "args": [ - "fastmcp-remote", - "https://example.com/mcp", - "--header", - "Authorization: Bearer <token>" - ] - } - } -} -``` - -Repeat `--header` to send multiple headers: - -```bash -uvx fastmcp-remote https://example.com/mcp \ - --header "Authorization: Bearer <token>" \ - --header "X-Workspace: production" \ - --header "X-Client-Name: My MCP Host" \ - --header "X-Callback-Url: https://example.com/oauth/callback" -``` - -Some MCP hosts on Windows have trouble preserving spaces inside command arguments. Put the spaced value in an environment variable and reference it from the header value: - -```json -{ - "mcpServers": { - "remote-api": { - "command": "uvx", - "args": [ - "fastmcp-remote", - "https://example.com/mcp", - "--header", - "Authorization:${AUTH_HEADER}" - ], - "env": { - "AUTH_HEADER": "Bearer <token>" - } - } - } -} -``` - -For local development servers over plain HTTP, disable OAuth when the server is unauthenticated: - -```bash -uvx fastmcp-remote http://localhost:8000/mcp --auth none -``` - -## Self-Signed Certificates - -For servers behind a self-signed certificate, point `--verify` at a CA bundle that trusts the certificate: - -```bash -uvx fastmcp-remote https://internal.example.com/mcp --verify /path/to/ca-bundle.pem -``` - -To disable certificate verification entirely, pass `--verify false`. This is insecure and should only be used for trusted servers on private networks: - -```bash -uvx fastmcp-remote https://internal.example.com/mcp --verify false -``` - -To trust a CA bundle without a flag, set the standard `SSL_CERT_FILE` environment variable, which OpenSSL reads automatically: - -```bash -SSL_CERT_FILE=/path/to/ca-bundle.pem uvx fastmcp-remote https://internal.example.com/mcp -``` - -## OAuth Storage - -OAuth tokens are stored under `~/.fastmcp/remote` by default. Set `FASTMCP_REMOTE_CONFIG_DIR` to use another directory: - -```bash -FASTMCP_REMOTE_CONFIG_DIR=~/.config/fastmcp-remote uvx fastmcp-remote https://example.com/mcp -``` - -Use `--resource` to isolate tokens for a particular remote server identity: - -```bash -uvx fastmcp-remote https://example.com/mcp --resource example-prod -``` - -If the remote authorization server requires a fixed callback port or hostname, pass them after the URL: - -```bash -uvx fastmcp-remote https://example.com/mcp 3334 --host 127.0.0.1 -``` - -## Options - -| Option | Description | -| ------ | ----------- | -| `--transport` | Choose `http` or `sse`. Defaults to `http`. | -| `--header` | Add a header to upstream requests, for example `--header "Authorization: Bearer <token>"`. Values may contain colons. Quote headers whose values contain spaces. Use `${VAR}` to expand environment variables inside values. Repeat for multiple headers. | -| `--auth` | Choose `oauth` or `none`. The default uses OAuth unless an `Authorization` header is provided. | -| `--verify` | Control TLS certificate verification. Pass a path to a CA bundle to trust a self-signed certificate, or `false` to disable verification (insecure). Defaults to verification enabled. | -| `--resource` | Isolate OAuth token storage for a named remote resource. | -| `--host` | Set the OAuth callback hostname. Defaults to `localhost`. | -| `--auth-timeout` | Set how long to wait for the OAuth callback. Defaults to 300 seconds. | -| `--ignore-tool` | Hide tools whose names match a glob pattern. Repeat for multiple patterns. | -| `--debug` | Enable debug logging. | -| `--silent` | Suppress non-critical logs. | diff --git a/docs/v3/clients/logging.mdx b/docs/v3/clients/logging.mdx deleted file mode 100644 index eea9ff322..000000000 --- a/docs/v3/clients/logging.mdx +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Server Logging -sidebarTitle: Logging -description: Receive and handle log messages from MCP servers. -icon: receipt ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.0.0" /> - -Use this when you need to capture or process log messages sent by the server. - -MCP servers can emit log messages to clients. The client handles these through a log handler callback. - -## Log Handler - -Provide a `log_handler` function when creating the client: - -```python -import logging -from fastmcp import Client -from fastmcp.client.logging import LogMessage - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) - -logger = logging.getLogger(__name__) -LOGGING_LEVEL_MAP = logging.getLevelNamesMapping() - -async def log_handler(message: LogMessage): - """Forward MCP server logs to Python's logging system.""" - msg = message.data.get('msg') - extra = message.data.get('extra') - - level = LOGGING_LEVEL_MAP.get(message.level.upper(), logging.INFO) - logger.log(level, msg, extra=extra) - -client = Client( - "my_mcp_server.py", - log_handler=log_handler, -) -``` - -The handler receives a `LogMessage` object: - -<Card icon="code" title="LogMessage"> -<ResponseField name="level" type='Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]'> - The log level -</ResponseField> - -<ResponseField name="logger" type="str | None"> - The logger name (may be None) -</ResponseField> - -<ResponseField name="data" type="dict"> - The log payload, containing `msg` and `extra` keys -</ResponseField> -</Card> - -## Structured Logs - -The `message.data` attribute is a dictionary containing the log payload. This enables structured logging with rich contextual information. - -```python -async def detailed_log_handler(message: LogMessage): - msg = message.data.get('msg') - extra = message.data.get('extra') - - if message.level == "error": - print(f"ERROR: {msg} | Details: {extra}") - elif message.level == "warning": - print(f"WARNING: {msg} | Details: {extra}") - else: - print(f"{message.level.upper()}: {msg}") -``` - -This structure is preserved even when logs are forwarded through a FastMCP proxy, making it useful for debugging multi-server applications. - -## Default Behavior - -If you do not provide a custom `log_handler`, FastMCP's default handler routes server logs to Python's logging system at the appropriate severity level. The MCP levels map as follows: `notice` becomes INFO; `alert` and `emergency` become CRITICAL. - -```python -client = Client("my_mcp_server.py") - -async with client: - # Server logs are forwarded at proper severity automatically - await client.call_tool("some_tool") -``` diff --git a/docs/v3/clients/notifications.mdx b/docs/v3/clients/notifications.mdx deleted file mode 100644 index 5e1b447aa..000000000 --- a/docs/v3/clients/notifications.mdx +++ /dev/null @@ -1,155 +0,0 @@ ---- -title: Notifications -sidebarTitle: Notifications -description: Handle server-sent notifications for list changes and other events. -icon: envelope ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<VersionBadge version="2.9.1" /> - -Use this when you need to react to server-side changes like tool list updates or resource modifications. - -MCP servers can send notifications to inform clients about state changes. The message handler provides a unified way to process these notifications. - -## Handling Notifications - -The simplest approach is a function that receives all messages and filters for the notifications you care about: - -```python -from fastmcp import Client - -async def message_handler(message): - """Handle MCP notifications from the server.""" - if hasattr(message, 'root'): - method = message.root.method - - if method == "notifications/tools/list_changed": - print("Tools have changed - refresh tool cache") - elif method == "notifications/resources/list_changed": - print("Resources have changed") - elif method == "notifications/prompts/list_changed": - print("Prompts have changed") - -client = Client( - "my_mcp_server.py", - message_handler=message_handler, -) -``` - -## MessageHandler Class - -For fine-grained targeting, subclass `MessageHandler` to use specific hooks: - -```python -from fastmcp import Client -from fastmcp.client.messages import MessageHandler -import mcp.types - -class MyMessageHandler(MessageHandler): - async def on_tool_list_changed( - self, notification: mcp.types.ToolListChangedNotification - ) -> None: - """Handle tool list changes.""" - print("Tool list changed - refreshing available tools") - - async def on_resource_list_changed( - self, notification: mcp.types.ResourceListChangedNotification - ) -> None: - """Handle resource list changes.""" - print("Resource list changed") - - async def on_prompt_list_changed( - self, notification: mcp.types.PromptListChangedNotification - ) -> None: - """Handle prompt list changes.""" - print("Prompt list changed") - -client = Client( - "my_mcp_server.py", - message_handler=MyMessageHandler(), -) -``` - -### Handler Template - -```python -from fastmcp.client.messages import MessageHandler -import mcp.types - -class MyMessageHandler(MessageHandler): - async def on_message(self, message) -> None: - """Called for ALL messages (requests and notifications).""" - pass - - async def on_notification( - self, notification: mcp.types.ServerNotification - ) -> None: - """Called for notifications (fire-and-forget).""" - pass - - async def on_tool_list_changed( - self, notification: mcp.types.ToolListChangedNotification - ) -> None: - """Called when the server's tool list changes.""" - pass - - async def on_resource_list_changed( - self, notification: mcp.types.ResourceListChangedNotification - ) -> None: - """Called when the server's resource list changes.""" - pass - - async def on_prompt_list_changed( - self, notification: mcp.types.PromptListChangedNotification - ) -> None: - """Called when the server's prompt list changes.""" - pass - - async def on_progress( - self, notification: mcp.types.ProgressNotification - ) -> None: - """Called for progress updates during long-running operations.""" - pass - - async def on_logging_message( - self, notification: mcp.types.LoggingMessageNotification - ) -> None: - """Called for log messages from the server.""" - pass -``` - -## List Change Notifications - -A practical example of maintaining a tool cache that refreshes when tools change: - -```python -from fastmcp import Client -from fastmcp.client.messages import MessageHandler -import mcp.types - -class ToolCacheHandler(MessageHandler): - def __init__(self): - self.cached_tools = [] - - async def on_tool_list_changed( - self, notification: mcp.types.ToolListChangedNotification - ) -> None: - """Clear tool cache when tools change.""" - print("Tools changed - clearing cache") - self.cached_tools = [] # Force refresh on next access - -client = Client("server.py", message_handler=ToolCacheHandler()) -``` - -## Server Requests - -While the message handler receives server-initiated requests, you should use dedicated callback parameters for most interactive scenarios: - -- **Sampling requests**: Use [`sampling_handler`](/clients/sampling) -- **Elicitation requests**: Use [`elicitation_handler`](/clients/elicitation) -- **Progress updates**: Use [`progress_handler`](/clients/progress) -- **Log messages**: Use [`log_handler`](/clients/logging) - -The message handler is primarily for monitoring and handling notifications rather than responding to requests. diff --git a/docs/v3/clients/progress.mdx b/docs/v3/clients/progress.mdx deleted file mode 100644 index 707ab8dac..000000000 --- a/docs/v3/clients/progress.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Progress Monitoring -sidebarTitle: Progress -description: Handle progress notifications from long-running server operations. -icon: bars-progress ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.3.5" /> - -Use this when you need to track progress of long-running operations. - -MCP servers can report progress during operations. The client receives these updates through a progress handler. - -## Progress Handler - -Set a handler when creating the client: - -```python -from fastmcp import Client - -async def progress_handler( - progress: float, - total: float | None, - message: str | None -) -> None: - if total is not None: - percentage = (progress / total) * 100 - print(f"Progress: {percentage:.1f}% - {message or ''}") - else: - print(f"Progress: {progress} - {message or ''}") - -client = Client( - "my_mcp_server.py", - progress_handler=progress_handler -) -``` - -The handler receives three parameters: - -<Card icon="code" title="Handler Parameters"> -<ResponseField name="progress" type="float"> - Current progress value -</ResponseField> - -<ResponseField name="total" type="float | None"> - Expected total value (may be None if unknown) -</ResponseField> - -<ResponseField name="message" type="str | None"> - Optional status message -</ResponseField> -</Card> - -## Per-Call Handler - -Override the client-level handler for specific tool calls: - -```python -async with client: - result = await client.call_tool( - "long_running_task", - {"param": "value"}, - progress_handler=my_progress_handler - ) -``` diff --git a/docs/v3/clients/prompts.mdx b/docs/v3/clients/prompts.mdx deleted file mode 100644 index bb50d475f..000000000 --- a/docs/v3/clients/prompts.mdx +++ /dev/null @@ -1,147 +0,0 @@ ---- -title: Getting Prompts -sidebarTitle: Prompts -description: Retrieve rendered message templates with automatic argument serialization. -icon: message-lines ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.0.0" /> - -Use this when you need to retrieve server-defined message templates for LLM interactions. - -Prompts are reusable message templates exposed by MCP servers. They can accept arguments to generate personalized message sequences for LLM interactions. - -## Basic Usage - -Request a rendered prompt with `get_prompt()`: - -```python -async with client: - # Simple prompt without arguments - result = await client.get_prompt("welcome_message") - # result -> mcp.types.GetPromptResult - - # Access the generated messages - for message in result.messages: - print(f"Role: {message.role}") - print(f"Content: {message.content}") -``` - -Pass arguments to customize the prompt: - -```python -async with client: - result = await client.get_prompt("user_greeting", { - "name": "Alice", - "role": "administrator" - }) - - for message in result.messages: - print(f"Generated message: {message.content}") -``` - -## Argument Serialization - -<VersionBadge version="2.9.0" /> - -FastMCP automatically serializes complex arguments to JSON strings as required by the MCP specification. You can pass typed objects directly: - -```python -from dataclasses import dataclass - -@dataclass -class UserData: - name: str - age: int - -async with client: - result = await client.get_prompt("analyze_user", { - "user": UserData(name="Alice", age=30), # Automatically serialized - "preferences": {"theme": "dark"}, # Dict serialized - "scores": [85, 92, 78], # List serialized - "simple_name": "Bob" # Strings unchanged - }) -``` - -The client handles serialization using `pydantic_core.to_json()` for consistent formatting. FastMCP servers automatically deserialize these JSON strings back to the expected types. - -## Working with Results - -The `get_prompt()` method returns a `GetPromptResult` containing a list of messages: - -```python -async with client: - result = await client.get_prompt("conversation_starter", {"topic": "climate"}) - - for i, message in enumerate(result.messages): - print(f"Message {i + 1}:") - print(f" Role: {message.role}") - print(f" Content: {message.content.text if hasattr(message.content, 'text') else message.content}") -``` - -Prompts can generate different message types. System messages configure LLM behavior: - -```python -async with client: - result = await client.get_prompt("system_configuration", { - "role": "helpful assistant", - "expertise": "python programming" - }) - - # Access the returned messages - message = result.messages[0] - print(f"Prompt: {message.content}") -``` - -Conversation templates generate multi-turn flows: - -```python -async with client: - result = await client.get_prompt("interview_template", { - "candidate_name": "Alice", - "position": "Senior Developer" - }) - - # Multiple messages for a conversation flow - for message in result.messages: - print(f"{message.role}: {message.content}") -``` - -## Version Selection - -<VersionBadge version="3.0.0" /> - -When a server exposes multiple versions of a prompt, you can request a specific version: - -```python -async with client: - # Get the highest version (default) - result = await client.get_prompt("summarize", {"text": "..."}) - - # Get a specific version - result_v1 = await client.get_prompt("summarize", {"text": "..."}, version="1.0") -``` - -See [Metadata](/servers/versioning#version-discovery) for how to discover available versions. - -## Multi-Server Clients - -When using multi-server clients, prompts are accessible directly without prefixing: - -```python -async with client: # Multi-server client - result1 = await client.get_prompt("weather_prompt", {"city": "London"}) - result2 = await client.get_prompt("assistant_prompt", {"query": "help"}) -``` - -## Raw Protocol Access - -For complete control, use `get_prompt_mcp()` which returns the full MCP protocol object: - -```python -async with client: - result = await client.get_prompt_mcp("example_prompt", {"arg": "value"}) - # result -> mcp.types.GetPromptResult -``` diff --git a/docs/v3/clients/resources.mdx b/docs/v3/clients/resources.mdx deleted file mode 100644 index a3e9300da..000000000 --- a/docs/v3/clients/resources.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: Reading Resources -sidebarTitle: Resources -description: Access static and templated data sources from MCP servers. -icon: folder-open ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.0.0" /> - -Use this when you need to read data from server-exposed resources like configuration files, generated content, or external data sources. - -Resources are data sources exposed by MCP servers. They can be static files with fixed content, or dynamic templates that generate content based on parameters in the URI. - -## Reading Resources - -Read a resource using its URI: - -```python -async with client: - content = await client.read_resource("file:///path/to/README.md") - # content -> list[TextResourceContents | BlobResourceContents] - - # Access text content - if hasattr(content[0], 'text'): - print(content[0].text) - - # Access binary content - if hasattr(content[0], 'blob'): - print(f"Binary data: {len(content[0].blob)} bytes") -``` - -Resource templates generate content based on URI parameters. The template defines a pattern like `weather://{{city}}/current`, and you fill in the parameters when reading: - -```python -async with client: - # Read from a resource template - weather_content = await client.read_resource("weather://london/current") - print(weather_content[0].text) -``` - -## Content Types - -Resources return different content types depending on what they expose. - -Text resources include configuration files, JSON data, and other human-readable content: - -```python -async with client: - content = await client.read_resource("resource://config/settings.json") - - for item in content: - if hasattr(item, 'text'): - print(f"Text content: {item.text}") - print(f"MIME type: {item.mimeType}") -``` - -Binary resources include images, PDFs, and other non-text data: - -```python -async with client: - content = await client.read_resource("resource://images/logo.png") - - for item in content: - if hasattr(item, 'blob'): - print(f"Binary content: {len(item.blob)} bytes") - print(f"MIME type: {item.mimeType}") - - # Save to file - with open("downloaded_logo.png", "wb") as f: - f.write(item.blob) -``` - -## Multi-Server Clients - -When using multi-server clients, resource URIs are prefixed with the server name: - -```python -async with client: # Multi-server client - weather_icons = await client.read_resource("weather://weather/icons/sunny") - templates = await client.read_resource("resource://assistant/templates/list") -``` - -## Version Selection - -<VersionBadge version="3.0.0" /> - -When a server exposes multiple versions of a resource, you can request a specific version: - -```python -async with client: - # Read the highest version (default) - content = await client.read_resource("data://config") - - # Read a specific version - content_v1 = await client.read_resource("data://config", version="1.0") -``` - -See [Metadata](/servers/versioning#version-discovery) for how to discover available versions. - -## Raw Protocol Access - -For complete control, use `read_resource_mcp()` which returns the full MCP protocol object: - -```python -async with client: - result = await client.read_resource_mcp("resource://example") - # result -> mcp.types.ReadResourceResult -``` diff --git a/docs/v3/clients/roots.mdx b/docs/v3/clients/roots.mdx deleted file mode 100644 index 0370c119a..000000000 --- a/docs/v3/clients/roots.mdx +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Client Roots -sidebarTitle: Roots -description: Provide local context and resource boundaries to MCP servers. -icon: folder-tree ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.0.0" /> - -Use this when you need to tell servers what local resources the client has access to. - -Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses. - -## Static Roots - -Provide a list of roots when creating the client: - -```python -from fastmcp import Client - -client = Client( - "my_mcp_server.py", - roots=["/path/to/root1", "/path/to/root2"] -) -``` - -## Dynamic Roots - -Use a callback to compute roots dynamically when the server requests them: - -```python -from fastmcp import Client -from fastmcp.client.roots import RequestContext - -async def roots_callback(context: RequestContext) -> list[str]: - print(f"Server requested roots (Request ID: {context.request_id})") - return ["/path/to/root1", "/path/to/root2"] - -client = Client( - "my_mcp_server.py", - roots=roots_callback -) -``` diff --git a/docs/v3/clients/sampling.mdx b/docs/v3/clients/sampling.mdx deleted file mode 100644 index 6b845c3bb..000000000 --- a/docs/v3/clients/sampling.mdx +++ /dev/null @@ -1,190 +0,0 @@ ---- -title: LLM Sampling -sidebarTitle: Sampling -description: Handle server-initiated LLM completion requests. -icon: robot ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<VersionBadge version="2.0.0" /> - -Use this when you need to respond to server requests for LLM completions. - -MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made. - -## Handler Template - -```python -from fastmcp import Client -from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext - -async def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - context: RequestContext -) -> str: - """ - Handle server requests for LLM completions. - - Args: - messages: Conversation messages to send to the LLM - params: Sampling parameters (temperature, max_tokens, etc.) - context: Request context with metadata - - Returns: - Generated text response from your LLM - """ - # Extract message content - conversation = [] - for message in messages: - content = message.content.text if hasattr(message.content, 'text') else str(message.content) - conversation.append(f"{message.role}: {content}") - - # Use the system prompt if provided - system_prompt = params.systemPrompt or "You are a helpful assistant." - - # Integrate with your LLM service here - return "Generated response based on the messages" - -client = Client( - "my_mcp_server.py", - sampling_handler=sampling_handler, -) -``` - -## Handler Parameters - -<Card icon="code" title="SamplingMessage"> -<ResponseField name="role" type='Literal["user", "assistant"]'> - The role of the message -</ResponseField> - -<ResponseField name="content" type="TextContent | ImageContent | AudioContent"> - The content of the message. TextContent has a `.text` attribute. -</ResponseField> -</Card> - -<Card icon="code" title="SamplingParams"> -<ResponseField name="systemPrompt" type="str | None"> - Optional system prompt the server wants to use -</ResponseField> - -<ResponseField name="modelPreferences" type="ModelPreferences | None"> - Server preferences for model selection (hints, cost/speed/intelligence priorities) -</ResponseField> - -<ResponseField name="temperature" type="float | None"> - Sampling temperature -</ResponseField> - -<ResponseField name="maxTokens" type="int"> - Maximum tokens to generate -</ResponseField> - -<ResponseField name="stopSequences" type="list[str] | None"> - Stop sequences for sampling -</ResponseField> - -<ResponseField name="tools" type="list[Tool] | None"> - Tools the LLM can use during sampling -</ResponseField> - -<ResponseField name="toolChoice" type="ToolChoice | None"> - Tool usage behavior (`auto`, `required`, or `none`) -</ResponseField> -</Card> - -## Built-in Handlers - -FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use. - -### OpenAI Handler - -<VersionBadge version="2.11.0" /> - -```python -from fastmcp import Client -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler - -client = Client( - "my_mcp_server.py", - sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"), -) -``` - -For OpenAI-compatible APIs (like local models): - -```python -from openai import AsyncOpenAI - -client = Client( - "my_mcp_server.py", - sampling_handler=OpenAISamplingHandler( - default_model="llama-3.1-70b", - client=AsyncOpenAI(base_url="http://localhost:8000/v1"), - ), -) -``` - -<Note> -Install the OpenAI handler with `pip install fastmcp[openai]`. -</Note> - -### Anthropic Handler - -<VersionBadge version="2.14.1" /> - -```python -from fastmcp import Client -from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler - -client = Client( - "my_mcp_server.py", - sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"), -) -``` - -<Note> -Install the Anthropic handler with `pip install fastmcp[anthropic]`. -</Note> - -### Google Gemini Handler - -<VersionBadge version="3.1.0" /> - -```python -from fastmcp import Client -from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHandler - -client = Client( - "my_mcp_server.py", - sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"), -) -``` - -<Note> -Install the Google Gemini handler with `pip install fastmcp[gemini]`. -</Note> - -## Sampling Capabilities - -When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers: - -```python -from mcp.types import SamplingCapability - -client = Client( - "my_mcp_server.py", - sampling_handler=basic_handler, - sampling_capabilities=SamplingCapability(), # No tool support -) -``` - -## Tool Execution - -Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results. - -<Tip> -To implement a custom sampling handler, see the [handler source code](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) as a reference. -</Tip> diff --git a/docs/v3/clients/tasks.mdx b/docs/v3/clients/tasks.mdx deleted file mode 100644 index ce27520e4..000000000 --- a/docs/v3/clients/tasks.mdx +++ /dev/null @@ -1,182 +0,0 @@ ---- -title: Background Tasks -sidebarTitle: Tasks -description: Execute operations asynchronously and track their progress. -icon: clock -tag: "NEW" ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.14.0" /> - -Use this when you need to run long operations asynchronously while doing other work. - -The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results. - -## Requesting Background Execution - -Pass `task=True` to run an operation as a background task: - -```python -from fastmcp import Client - -async with Client(server) as client: - # Start a background task - task = await client.call_tool("slow_computation", {"duration": 10}, task=True) - - print(f"Task started: {task.task_id}") - - # Do other work while it runs... - - # Get the result when ready - result = await task.result() -``` - -This works with tools, resources, and prompts: - -```python -tool_task = await client.call_tool("my_tool", args, task=True) -resource_task = await client.read_resource("file://large.txt", task=True) -prompt_task = await client.get_prompt("my_prompt", args, task=True) -``` - -## Task API - -All task types share a common interface. - -### Getting Results - -Call `await task.result()` or simply `await task` to block until the task completes: - -```python -task = await client.call_tool("analyze", {"text": "hello"}, task=True) - -# Wait for result (blocking) -result = await task.result() -# or: result = await task -``` - -### Checking Status - -Check the current status without blocking: - -```python -status = await task.status() -print(f"{status.status}: {status.statusMessage}") -# status.status is "working", "completed", "failed", or "cancelled" -``` - -### Waiting with Control - -Use `task.wait()` for more control over waiting: - -```python -# Wait up to 30 seconds for completion -status = await task.wait(timeout=30.0) - -# Wait for a specific state -status = await task.wait(state="completed", timeout=30.0) -``` - -### Cancellation - -Cancel a running task: - -```python -await task.cancel() -``` - -## Status Updates - -Register callbacks to receive real-time status updates as the server reports progress: - -```python -def on_status_change(status): - print(f"Task {status.taskId}: {status.status} - {status.statusMessage}") - -task.on_status_change(on_status_change) - -# Async callbacks work too -async def on_status_async(status): - await log_status(status) - -task.on_status_change(on_status_async) -``` - -### Handler Template - -```python -from fastmcp import Client - -def status_handler(status): - """ - Handle task status updates. - - Args: - status: Task status object with: - - taskId: Unique task identifier - - status: "working", "completed", "failed", or "cancelled" - - statusMessage: Optional progress message from server - """ - if status.status == "working": - print(f"Progress: {status.statusMessage}") - elif status.status == "completed": - print("Task completed") - elif status.status == "failed": - print(f"Task failed: {status.statusMessage}") - -task.on_status_change(status_handler) -``` - -## Graceful Degradation - -You can always pass `task=True` regardless of whether the server supports background tasks. Per the MCP specification, servers without task support execute the operation immediately and return the result inline. - -```python -task = await client.call_tool("my_tool", args, task=True) - -if task.returned_immediately: - print("Server executed immediately (no background support)") -else: - print("Running in background") - -# Either way, this works -result = await task.result() -``` - -This lets you write task-aware client code without worrying about server capabilities. - -## Example - -```python -import asyncio -from fastmcp import Client - -async def main(): - async with Client(server) as client: - # Start background task - task = await client.call_tool( - "slow_computation", - {"duration": 10}, - task=True, - ) - - # Subscribe to updates - def on_update(status): - print(f"Progress: {status.statusMessage}") - - task.on_status_change(on_update) - - # Do other work while task runs - print("Doing other work...") - await asyncio.sleep(2) - - # Wait for completion and get result - result = await task.result() - print(f"Result: {result.content}") - -asyncio.run(main()) -``` - -See [Server Background Tasks](/servers/tasks) for how to enable background task support on the server side. diff --git a/docs/v3/clients/tools.mdx b/docs/v3/clients/tools.mdx deleted file mode 100644 index 1541f593e..000000000 --- a/docs/v3/clients/tools.mdx +++ /dev/null @@ -1,183 +0,0 @@ ---- -title: Calling Tools -sidebarTitle: Tools -description: Execute server-side tools and handle structured results. -icon: wrench ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.0.0" /> - -Use this when you need to execute server-side functions and process their results. - -Tools are executable functions exposed by MCP servers. The client's `call_tool()` method executes a tool by name with arguments and returns structured results. - -## Basic Execution - -```python -async with client: - result = await client.call_tool("add", {"a": 5, "b": 3}) - # result -> CallToolResult with structured and unstructured data - - # Access structured data (automatically deserialized) - print(result.data) # 8 - - # Access traditional content blocks - print(result.content[0].text) # "8" -``` - -Arguments are passed as a dictionary. For multi-server clients, tool names are automatically prefixed with the server name (e.g., `weather_get_forecast` for a tool named `get_forecast` on the `weather` server). - -## Execution Options - -The `call_tool()` method supports timeout control and progress monitoring: - -```python -async with client: - # With timeout (aborts if execution takes longer than 2 seconds) - result = await client.call_tool( - "long_running_task", - {"param": "value"}, - timeout=2.0 - ) - - # With progress handler - result = await client.call_tool( - "long_running_task", - {"param": "value"}, - progress_handler=my_progress_handler - ) -``` - -## Structured Results - -<VersionBadge version="2.10.0" /> - -Tool execution returns a `CallToolResult` object. The `.data` property provides fully hydrated Python objects including complex types like datetimes and UUIDs, reconstructed from the server's output schema. - -```python -from datetime import datetime -from uuid import UUID - -async with client: - result = await client.call_tool("get_weather", {"city": "London"}) - - # FastMCP reconstructs complete Python objects - weather = result.data - print(f"Temperature: {weather.temperature}C at {weather.timestamp}") - - # Complex types are properly deserialized - assert isinstance(weather.timestamp, datetime) - assert isinstance(weather.station_id, UUID) - - # Raw structured JSON is also available - print(f"Raw JSON: {result.structured_content}") -``` - -<Card icon="code" title="CallToolResult Properties"> -<ResponseField name=".data" type="Any"> - Fully hydrated Python objects with complex type support (datetimes, UUIDs, custom classes). FastMCP exclusive. -</ResponseField> - -<ResponseField name=".content" type="list[mcp.types.ContentBlock]"> - Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.). -</ResponseField> - -<ResponseField name=".structured_content" type="dict[str, Any] | None"> - Standard MCP structured JSON data as sent by the server. -</ResponseField> - -<ResponseField name=".is_error" type="bool"> - Boolean indicating if the tool execution failed. -</ResponseField> -</Card> - -For tools without output schemas or when deserialization fails, `.data` will be `None`. Fall back to content blocks in that case: - -```python -async with client: - result = await client.call_tool("legacy_tool", {"param": "value"}) - - if result.data is not None: - print(f"Structured: {result.data}") - else: - for content in result.content: - if hasattr(content, 'text'): - print(f"Text result: {content.text}") -``` - -<Tip> -FastMCP servers automatically wrap primitive results (like `int`, `str`, `bool`) in a `{"result": value}` structure. FastMCP clients automatically unwrap this, so you get the original value in `.data`. -</Tip> - -## Error Handling - -By default, `call_tool()` raises a `ToolError` if the tool execution fails: - -```python -from fastmcp.exceptions import ToolError - -async with client: - try: - result = await client.call_tool("potentially_failing_tool", {"param": "value"}) - print("Tool succeeded:", result.data) - except ToolError as e: - print(f"Tool failed: {e}") -``` - -To handle errors manually instead of catching exceptions, disable automatic error raising: - -```python -async with client: - result = await client.call_tool( - "potentially_failing_tool", - {"param": "value"}, - raise_on_error=False - ) - - if result.is_error: - print(f"Tool failed: {result.content[0].text}") - else: - print(f"Tool succeeded: {result.data}") -``` - -## Sending Metadata - -<VersionBadge version="2.13.1" /> - -The `meta` parameter sends ancillary information alongside tool calls for observability, debugging, or client identification: - -```python -async with client: - result = await client.call_tool( - name="send_email", - arguments={ - "to": "user@example.com", - "subject": "Hello", - "body": "Welcome!" - }, - meta={ - "trace_id": "abc-123", - "request_source": "mobile_app" - } - ) -``` - -See [Client Metadata](/servers/context#client-metadata) to learn how servers access this data. - -## Raw Protocol Access - -For complete control, use `call_tool_mcp()` which returns the raw MCP protocol object: - -```python -async with client: - result = await client.call_tool_mcp("my_tool", {"param": "value"}) - # result -> mcp.types.CallToolResult - - if result.isError: - print(f"Tool failed: {result.content}") - else: - print(f"Tool succeeded: {result.content}") - # Note: No automatic deserialization with call_tool_mcp() -``` diff --git a/docs/v3/clients/transports.mdx b/docs/v3/clients/transports.mdx deleted file mode 100644 index efcda3366..000000000 --- a/docs/v3/clients/transports.mdx +++ /dev/null @@ -1,267 +0,0 @@ ---- -title: Client Transports -sidebarTitle: Transports -description: Configure how clients connect to and communicate with MCP servers. -icon: link ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.0.0" /> - -Transports handle the underlying connection between your client and MCP servers. While the client can automatically select a transport based on what you pass to it, instantiating transports explicitly gives you full control over configuration. - -## STDIO Transport - -STDIO transport communicates with MCP servers through subprocess pipes. When using STDIO, your client launches and manages the server process, controlling its lifecycle and environment. - -<Warning> -STDIO servers run in isolated environments by default. They do not inherit your shell's environment variables. You must explicitly pass any configuration the server needs. -</Warning> - -```python -from fastmcp import Client -from fastmcp.client.transports import StdioTransport - -transport = StdioTransport( - command="python", - args=["my_server.py", "--verbose"], - env={"API_KEY": "secret", "LOG_LEVEL": "DEBUG"}, - cwd="/path/to/server" -) -client = Client(transport) -``` - -For convenience, the client can infer STDIO transport from file paths, though this limits configuration options: - -```python -from fastmcp import Client - -client = Client("my_server.py") # Limited - no configuration options -``` - -### Environment Variables - -Since STDIO servers do not inherit your environment, you need strategies for passing configuration. - -**Selective forwarding** passes only the variables your server needs: - -```python -import os -from fastmcp.client.transports import StdioTransport - -required_vars = ["API_KEY", "DATABASE_URL", "REDIS_HOST"] -env = {var: os.environ[var] for var in required_vars if var in os.environ} - -transport = StdioTransport(command="python", args=["server.py"], env=env) -client = Client(transport) -``` - -**Loading from .env files** keeps configuration separate from code: - -```python -from dotenv import dotenv_values -from fastmcp.client.transports import StdioTransport - -env = dotenv_values(".env") -transport = StdioTransport(command="python", args=["server.py"], env=env) -client = Client(transport) -``` - -### Session Persistence - -STDIO transports maintain sessions across multiple client contexts by default (`keep_alive=True`). This reuses the same subprocess for multiple connections, improving performance. - -```python -from fastmcp.client.transports import StdioTransport - -transport = StdioTransport(command="python", args=["server.py"]) -client = Client(transport) - -async def efficient_multiple_operations(): - async with client: - await client.ping() - - async with client: # Reuses the same subprocess - await client.call_tool("process_data", {"file": "data.csv"}) -``` - -For complete isolation between connections, disable session persistence: - -```python -transport = StdioTransport(command="python", args=["server.py"], keep_alive=False) -``` - -## HTTP Transport - -<VersionBadge version="2.3.0" /> - -HTTP transport connects to MCP servers running as web services. This is the recommended transport for production deployments. - -```python -from fastmcp import Client -from fastmcp.client.transports import StreamableHttpTransport - -transport = StreamableHttpTransport( - url="https://api.example.com/mcp", - headers={ - "Authorization": "Bearer your-token-here", - "X-Custom-Header": "value" - } -) -client = Client(transport) -``` - -FastMCP also provides authentication helpers: - -```python -from fastmcp import Client -from fastmcp.client.auth import BearerAuth - -client = Client( - "https://api.example.com/mcp", - auth=BearerAuth("your-token-here") -) -``` - -### SSL Verification - -By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as [httpx](https://www.python-httpx.org/advanced/ssl/): - -```python -from fastmcp import Client - -# Disable SSL verification (e.g., for self-signed certs in development) -client = Client("https://dev-server.internal/mcp", verify=False) - -# Use a custom CA bundle -client = Client("https://corp-server.internal/mcp", verify="/path/to/ca-bundle.pem") - -# Use a custom SSL context for full control -import ssl -ctx = ssl.create_default_context() -ctx.load_verify_locations("/path/to/internal-ca.pem") -client = Client("https://corp-server.internal/mcp", verify=ctx) -``` - -The `verify` parameter is also available directly on `StreamableHttpTransport` and `SSETransport`: - -```python -from fastmcp.client.transports import StreamableHttpTransport - -transport = StreamableHttpTransport( - url="https://dev-server.internal/mcp", - verify=False, -) -client = Client(transport) -``` - -### SSE Transport - -Server-Sent Events transport is maintained for backward compatibility. Use Streamable HTTP for new deployments unless you have specific infrastructure requirements. - -```python -from fastmcp.client.transports import SSETransport - -transport = SSETransport( - url="https://api.example.com/sse", - headers={"Authorization": "Bearer token"} -) -client = Client(transport) -``` - -## In-Memory Transport - -In-memory transport connects directly to a FastMCP server instance within the same Python process. This eliminates both subprocess management and network overhead, making it ideal for testing. - -```python -from fastmcp import FastMCP, Client -import os - -mcp = FastMCP("TestServer") - -@mcp.tool -def greet(name: str) -> str: - prefix = os.environ.get("GREETING_PREFIX", "Hello") - return f"{prefix}, {name}!" - -client = Client(mcp) - -async with client: - result = await client.call_tool("greet", {"name": "World"}) -``` - -<Note> -Unlike STDIO transports, in-memory servers share the same memory space and environment variables as your client code. -</Note> - -## Multi-Server Configuration - -<VersionBadge version="2.4.0" /> - -Connect to multiple servers defined in a configuration dictionary: - -```python -from fastmcp import Client - -config = { - "mcpServers": { - "weather": { - "url": "https://weather.example.com/mcp", - "transport": "http" - }, - "assistant": { - "command": "python", - "args": ["./assistant.py"], - "env": {"LOG_LEVEL": "INFO"} - } - } -} - -client = Client(config) - -async with client: - # Tools are namespaced by server - weather = await client.call_tool("weather_get_forecast", {"city": "NYC"}) - answer = await client.call_tool("assistant_ask", {"question": "What?"}) -``` - -### Tool Transformations - -FastMCP supports tool transformations within the configuration. You can change names, descriptions, tags, and arguments for tools from a server. - -```python -config = { - "mcpServers": { - "weather": { - "url": "https://weather.example.com/mcp", - "transport": "http", - "tools": { - "weather_get_forecast": { - "name": "miami_weather", - "description": "Get the weather for Miami", - "arguments": { - "city": { - "default": "Miami", - "hide": True, - } - } - } - } - } - } -} -``` - -To filter tools by tag, use `include_tags` or `exclude_tags` at the server level: - -```python -config = { - "mcpServers": { - "weather": { - "url": "https://weather.example.com/mcp", - "include_tags": ["forecast"] # Only tools with this tag - } - } -} -``` diff --git a/docs/v3/community/README.md b/docs/v3/community/README.md deleted file mode 100644 index b61f5cf6d..000000000 --- a/docs/v3/community/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Community Section - -This directory contains community-contributed content and showcases for FastMCP. - -## Structure - -- `showcase.mdx` - Main community showcase page featuring high-quality projects and examples - -## Adding Content - -To add new community content: -1. Create a new MDX file in this directory -2. Update `docs.json` to include it in the navigation -3. Follow the existing format for consistency - -## Guidelines - -Community content should: -- Demonstrate best practices -- Provide educational value -- Include proper documentation -- Be maintained and up-to-date \ No newline at end of file diff --git a/docs/v3/community/showcase.mdx b/docs/v3/community/showcase.mdx deleted file mode 100644 index 9ba4c6877..000000000 --- a/docs/v3/community/showcase.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: 'Community Showcase' -description: 'High-quality projects and examples from the FastMCP community' -icon: 'users' ---- - -import { YouTubeEmbed } from '/snippets/youtube-embed.mdx' - -## Join the Community - -<Card title="FastMCP Discord" icon="discord" href="https://discord.gg/uu8dJCgttd"> - Connect with other FastMCP developers, share your projects, and discuss ideas. -</Card> - -## Featured Projects - -Discover exemplary MCP servers and implementations created by our community. These projects demonstrate best practices and innovative uses of FastMCP. - -### Learning Resources - -<Card title="MCP Dummy Server" icon="graduation-cap" href="https://github.com/WaiYanNyeinNaing/mcp-dummy-server"> - A comprehensive educational example demonstrating FastMCP best practices with professional dual-transport server implementation, interactive test client, and detailed documentation. -</Card> - -#### Video Tutorials - -**Build Remote MCP Servers w/ Python & FastMCP** - Claude Integrations Tutorial by Greg + Code - -<YouTubeEmbed - videoId="bOYkbXP-GGo" - title="Build Remote MCP Servers w/ Python & FastMCP" -/> - -**FastMCP — the best way to build an MCP server with Python** - Tutorial by ZazenCodes - -<YouTubeEmbed - videoId="rnljvmHorQw" - title="FastMCP — the best way to build an MCP server with Python" -/> - -**Speedrun a MCP server for Claude Desktop (fastmcp)** - Tutorial by Nate from Prefect - -<YouTubeEmbed - videoId="67ZwpkUEtSI" - title="Speedrun a MCP server for Claude Desktop (fastmcp)" -/> - -### Community Examples - -Have you built something interesting with FastMCP? We'd love to feature high-quality examples here! Start a [discussion on GitHub](https://github.com/PrefectHQ/fastmcp/discussions) to share your project. - -## Contributing - -To get your project featured: - -1. Ensure your project demonstrates best practices -2. Include comprehensive documentation -3. Add clear usage examples -4. Open a discussion in our [GitHub Discussions](https://github.com/PrefectHQ/fastmcp/discussions) - -We review submissions regularly and feature projects that provide value to the FastMCP community. - -## Further Reading - -- [Contrib Modules](/patterns/contrib) - Community-contributed modules that are distributed with FastMCP itself \ No newline at end of file diff --git a/docs/v3/deployment/http.mdx b/docs/v3/deployment/http.mdx deleted file mode 100644 index 16c9fadfa..000000000 --- a/docs/v3/deployment/http.mdx +++ /dev/null @@ -1,924 +0,0 @@ ---- -title: HTTP Deployment -sidebarTitle: HTTP Deployment -description: Deploy your FastMCP server over HTTP for remote access -icon: server ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<Tip> -STDIO transport is perfect for local development and desktop applications. But to unlock the full potential of MCP—centralized services, multi-client access, and network availability—you need remote HTTP deployment. -</Tip> - -This guide walks you through deploying your FastMCP server as a remote MCP service that's accessible via a URL. Once deployed, your MCP server will be available over the network, allowing multiple clients to connect simultaneously and enabling integration with cloud-based LLM applications. This guide focuses specifically on remote MCP deployment, not local STDIO servers. - -## Choosing Your Approach - -FastMCP provides two ways to deploy your server as an HTTP service. Understanding the trade-offs helps you choose the right approach for your needs. - -The **direct HTTP server** approach is simpler and perfect for getting started quickly. You modify your server's `run()` method to use HTTP transport, and FastMCP handles all the web server configuration. This approach works well for standalone deployments where you want your MCP server to be the only service running on a port. - -The **ASGI application** approach gives you more control and flexibility. Instead of running the server directly, you create an ASGI application that can be served by Uvicorn. This approach is better when you need advanced server features like multiple workers, custom middleware, or when you're integrating with existing web applications. - -### Direct HTTP Server - -The simplest way to get your MCP server online is to use the built-in `run()` method with HTTP transport. This approach handles all the server configuration for you and is ideal when you want a standalone MCP server without additional complexity. - -```python server.py -from fastmcp import FastMCP - -mcp = FastMCP("My Server") - -@mcp.tool -def process_data(input: str) -> str: - """Process data on the server""" - return f"Processed: {input}" - -if __name__ == "__main__": - mcp.run(transport="http", host="0.0.0.0", port=8000) -``` - -Run your server with a simple Python command: -```bash -python server.py -``` - -Your server is now accessible at `http://localhost:8000/mcp` (or use your server's actual IP address for remote access). - -This approach is ideal when you want to get online quickly with minimal configuration. It's perfect for internal tools, development environments, or simple deployments where you don't need advanced server features. The built-in server handles all the HTTP details, letting you focus on your MCP implementation. - -### ASGI Application - -For production deployments, you'll often want more control over how your server runs. FastMCP can create a standard ASGI application that works with any ASGI server like Uvicorn, Gunicorn, or Hypercorn. This approach is particularly useful when you need to configure advanced server options, run multiple workers, or integrate with existing infrastructure. - -```python app.py -from fastmcp import FastMCP - -mcp = FastMCP("My Server") - -@mcp.tool -def process_data(input: str) -> str: - """Process data on the server""" - return f"Processed: {input}" - -# Create ASGI application -app = mcp.http_app() -``` - -Run with any ASGI server - here's an example with Uvicorn: -```bash -uvicorn app:app --host 0.0.0.0 --port 8000 -``` - -Your server is accessible at the same URL: `http://localhost:8000/mcp` (or use your server's actual IP address for remote access). - -The ASGI approach shines in production environments where you need reliability and performance. You can run multiple worker processes to handle concurrent requests, add custom middleware for logging or monitoring, integrate with existing deployment pipelines, or mount your MCP server as part of a larger application. - -## Configuring Your Server - -### Custom Path - -By default, your MCP server is accessible at `/mcp/` on your domain. You can customize this path to fit your URL structure or avoid conflicts with existing endpoints. This is particularly useful when integrating MCP into an existing application or following specific API conventions. - -```python -# Option 1: With mcp.run() -mcp.run(transport="http", host="0.0.0.0", port=8000, path="/api/mcp/") - -# Option 2: With ASGI app -app = mcp.http_app(path="/api/mcp/") -``` - -Now your server is accessible at `http://localhost:8000/api/mcp/`. - -### Authentication - -<Warning> -Authentication is **highly recommended** for remote MCP servers. Some LLM clients require authentication for remote servers and will refuse to connect without it. -</Warning> - -FastMCP supports multiple authentication methods to secure your remote server. See the [Authentication Overview](/servers/auth/authentication) for complete configuration options including Bearer tokens, JWT, and OAuth. - -If you're mounting an authenticated server under a path prefix, see [Mounting Authenticated Servers](#mounting-authenticated-servers) below for important routing considerations. - -### Host and Origin Protection - -FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it remains opt-in in FastMCP 3.x to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments. - -Think of this as a request guard rather than CORS middleware. It decides whether a request can reach MCP session handling. CORS remains a separate browser response-header policy; configure CORS middleware separately when browser JavaScript must read cross-origin responses. - -Enable strict validation with `host_origin_protection=True`. When you deploy behind a public hostname, add the hostname clients use to reach your MCP endpoint. If a browser-based MCP client runs on a separate origin, add that origin as well: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("My Server") - -app = mcp.http_app( - host_origin_protection=True, - allowed_hosts=["mcp.example.com"], - allowed_origins=["https://app.example.com"], -) -``` - -For the direct server approach, pass the same values to `run()`: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("My Server") - -if __name__ == "__main__": - mcp.run( - transport="http", - host="0.0.0.0", - port=8000, - host_origin_protection=True, - allowed_hosts=["mcp.example.com"], - allowed_origins=["https://app.example.com"], - ) -``` - -You can also configure these values with environment variables: - -```bash -export FASTMCP_HTTP_HOST_ORIGIN_PROTECTION=true -export FASTMCP_HTTP_ALLOWED_HOSTS='["mcp.example.com"]' -export FASTMCP_HTTP_ALLOWED_ORIGINS='["https://app.example.com"]' -``` - -Use `host_origin_protection="auto"` to protect localhost-bound direct servers while allowing ASGI, serverless, and reverse-proxy deployments to keep their existing Host handling unless they configure explicit trust rules. Use `host_origin_protection=False` to keep the request guard disabled. - -### Health Checks - -Health check endpoints are essential for monitoring your deployed server and ensuring it's responding correctly. FastMCP allows you to add custom routes alongside your MCP endpoints, making it easy to implement health checks that work with both deployment approaches. - -```python -from starlette.responses import JSONResponse - -@mcp.custom_route("/health", methods=["GET"]) -async def health_check(request): - return JSONResponse({"status": "healthy", "service": "mcp-server"}) -``` - -This health endpoint will be available at `http://localhost:8000/health` and can be used by load balancers, monitoring systems, or deployment platforms to verify your server is running. - -<Note> -Custom routes are never protected by the server's authentication middleware, even when an `AuthProvider` is configured. This is by design — the primary use case for custom routes is unauthenticated operational endpoints like health checks and readiness probes. If you need authenticated HTTP endpoints alongside your MCP server, [mount it in a FastAPI app](/integrations/fastapi) and use FastAPI's `Depends()` for auth on your routes. -</Note> - -### Custom Middleware - - -<VersionBadge version="2.3.2" /> - -Add custom Starlette middleware to your FastMCP ASGI apps: - -```python -from fastmcp import FastMCP -from starlette.middleware import Middleware -from starlette.middleware.cors import CORSMiddleware - -# Create your FastMCP server -mcp = FastMCP("MyServer") - -# Define middleware -middleware = [ - Middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - ) -] - -# Create ASGI app with middleware -http_app = mcp.http_app(middleware=middleware) -``` - -### CORS for Browser-Based Clients - -<Tip> -Most MCP clients, including those that you access through a browser like ChatGPT or Claude, don't need CORS configuration. Only enable CORS if you're working with an MCP client that connects directly from a browser, such as debugging tools or inspectors. -</Tip> - -CORS (Cross-Origin Resource Sharing) is needed when JavaScript running in a web browser connects directly to your MCP server. This is different from using an LLM through a browser—in that case, the browser connects to the LLM service, and the LLM service connects to your MCP server (no CORS needed). - -Host and Origin protection runs before CORS when it is active for a request. Add browser client origins to `allowed_origins` so trusted browser requests reach the CORS middleware, then configure CORS to let browser JavaScript read the MCP response headers it needs. Setting `allowed_origins` trusts the request; it does not emit `Access-Control-Allow-Origin` or other CORS response headers. - -Browser-based MCP clients that need CORS include: - -- **MCP Inspector** - Browser-based debugging tool for testing MCP servers -- **Custom browser-based MCP clients** - If you're building a web app that directly connects to MCP servers - -For these scenarios, add CORS middleware with the specific headers required for MCP protocol: - -```python -from fastmcp import FastMCP -from starlette.middleware import Middleware -from starlette.middleware.cors import CORSMiddleware - -mcp = FastMCP("MyServer") - -# Configure CORS for browser-based clients -middleware = [ - Middleware( - CORSMiddleware, - allow_origins=["*"], # Allow all origins; use specific origins for security - allow_methods=["GET", "POST", "DELETE", "OPTIONS"], - allow_headers=[ - "mcp-protocol-version", - "mcp-session-id", - "Authorization", - "Content-Type", - ], - expose_headers=["mcp-session-id"], - ) -] - -app = mcp.http_app(middleware=middleware) -``` - -**Key configuration details:** - -- **`allow_origins`**: Specify exact origins (e.g., `["http://localhost:3000"]`) rather than `["*"]` for production deployments -- **`allow_headers`**: Must include `mcp-protocol-version`, `mcp-session-id`, and `Authorization` (for authenticated servers) -- **`expose_headers`**: Must include `mcp-session-id` so JavaScript can read the session ID from responses and send it in subsequent requests - -Without `expose_headers=["mcp-session-id"]`, browsers will receive the session ID but JavaScript won't be able to access it, causing session management to fail. - -<Warning> -**Production Security**: Never use `allow_origins=["*"]` in production. Specify the exact origins of your browser-based clients. Using wildcards exposes your server to unauthorized access from any website. -</Warning> - -### SSE Polling for Long-Running Operations - -<VersionBadge version="2.14.0" /> - -<Note> -This feature only applies to the **StreamableHTTP transport** (the default for `http_app()`). It does not apply to the legacy SSE transport (`transport="sse"`). -</Note> - -When running tools that take a long time to complete, you may encounter issues with load balancers or proxies terminating connections that stay idle too long. [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699) introduces SSE polling to solve this by allowing the server to gracefully close connections and have clients automatically reconnect. - -To enable SSE polling, configure an `EventStore` when creating your HTTP application: - -```python -from fastmcp import FastMCP, Context -from fastmcp.server.event_store import EventStore - -mcp = FastMCP("My Server") - -@mcp.tool -async def long_running_task(ctx: Context) -> str: - """A task that takes several minutes to complete.""" - for i in range(100): - await ctx.report_progress(i, 100) - - # Periodically close the connection to avoid load balancer timeouts - # Client will automatically reconnect and resume receiving progress - if i % 30 == 0 and i > 0: - await ctx.close_sse_stream() - - await do_expensive_work() - - return "Done!" - -# Configure with EventStore for resumability -event_store = EventStore() -app = mcp.http_app( - event_store=event_store, - retry_interval=2000, # Client reconnects after 2 seconds -) -``` - -**How it works:** - -1. When `event_store` is configured, the server stores all events (progress updates, results) with unique IDs -2. Calling `ctx.close_sse_stream()` gracefully closes the HTTP connection -3. The client automatically reconnects with a `Last-Event-ID` header -4. The server replays any events the client missed during the disconnection - -The `retry_interval` parameter (in milliseconds) controls how long clients wait before reconnecting. Choose a value that balances responsiveness with server load. - -<Note> -`close_sse_stream()` is a no-op if called without an `EventStore` configured, so you can safely include it in tools that may run in different deployment configurations. -</Note> - -#### Custom Storage Backends - -By default, `EventStore` uses in-memory storage. For production deployments with multiple server instances, you can provide a custom storage backend using the `key_value` package: - -```python -from fastmcp.server.event_store import EventStore -from key_value.aio.stores.redis import RedisStore - -# Use Redis for distributed deployments -redis_store = RedisStore(url="redis://localhost:6379") -event_store = EventStore( - storage=redis_store, - max_events_per_stream=100, # Keep last 100 events per stream - ttl=3600, # Events expire after 1 hour -) - -app = mcp.http_app(event_store=event_store) -``` - -## Integration with Web Frameworks - -If you already have a web application running, you can add MCP capabilities by mounting a FastMCP server as a sub-application. This allows you to expose MCP tools alongside your existing API endpoints, sharing the same domain and infrastructure. The MCP server becomes just another route in your application, making it easy to manage and deploy. - -### Mounting in Starlette - -Mount your FastMCP server in a Starlette application: - -```python -from fastmcp import FastMCP -from starlette.applications import Starlette -from starlette.routing import Mount - -# Create your FastMCP server -mcp = FastMCP("MyServer") - -@mcp.tool -def analyze(data: str) -> dict: - return {"result": f"Analyzed: {data}"} - -# Create the ASGI app -mcp_app = mcp.http_app(path='/mcp') - -# Create a Starlette app and mount the MCP server -app = Starlette( - routes=[ - Mount("/mcp-server", app=mcp_app), - # Add other routes as needed - ], - lifespan=mcp_app.lifespan, -) -``` - -The MCP endpoint will be available at `/mcp-server/mcp/` of the resulting Starlette app. - -<Warning> -For Streamable HTTP transport, you **must** pass the lifespan context from the FastMCP app to the resulting Starlette app, as nested lifespans are not recognized. Otherwise, the FastMCP server's session manager will not be properly initialized. -</Warning> - -#### Nested Mounts - -You can create complex routing structures by nesting mounts: - -```python -from fastmcp import FastMCP -from starlette.applications import Starlette -from starlette.routing import Mount - -# Create your FastMCP server -mcp = FastMCP("MyServer") - -# Create the ASGI app -mcp_app = mcp.http_app(path='/mcp') - -# Create nested application structure -inner_app = Starlette(routes=[Mount("/inner", app=mcp_app)]) -app = Starlette( - routes=[Mount("/outer", app=inner_app)], - lifespan=mcp_app.lifespan, -) -``` - -In this setup, the MCP server is accessible at the `/outer/inner/mcp/` path. - -### FastAPI Integration - -For FastAPI-specific integration patterns including both mounting MCP servers into FastAPI apps and generating MCP servers from FastAPI apps, see the [FastAPI Integration guide](/integrations/fastapi). - -Here's a quick example showing how to add MCP to an existing FastAPI application: - -```python -from fastapi import FastAPI -from fastmcp import FastMCP - -# Create your MCP server -mcp = FastMCP("API Tools") - -@mcp.tool -def query_database(query: str) -> dict: - """Run a database query""" - return {"result": "data"} - -# Create the MCP ASGI app with path="/" since we'll mount at /mcp -mcp_app = mcp.http_app(path="/") - -# Create FastAPI app with MCP lifespan (required for session management) -api = FastAPI(lifespan=mcp_app.lifespan) - -@api.get("/api/status") -def status(): - return {"status": "ok"} - -# Mount MCP at /mcp -api.mount("/mcp", mcp_app) - -# Run with: uvicorn app:api --host 0.0.0.0 --port 8000 -``` - -Your existing API remains at `http://localhost:8000/api` while MCP is available at `http://localhost:8000/mcp`. - -<Warning> -Just like with Starlette, you **must** pass the lifespan from the MCP app to FastAPI. Without this, the session manager won't initialize properly and requests will fail. -</Warning> - -## Mounting Authenticated Servers - -<VersionBadge version="2.13.0" /> - -<Tip> -This section only applies if you're **mounting an OAuth-protected FastMCP server under a path prefix** (like `/api`) inside another application using `Mount()`. - -If you're deploying your FastMCP server at root level without any `Mount()` prefix, the well-known routes are automatically included in `mcp.http_app()` and you don't need to do anything special. -</Tip> - -OAuth specifications (RFC 8414 and RFC 9728) require discovery metadata to be accessible at well-known paths under the root level of your domain. When you mount an OAuth-protected FastMCP server under a path prefix like `/api`, this creates a routing challenge: your operational OAuth endpoints move under the prefix, but discovery endpoints must remain at the root. - -<Warning> -**Common Mistakes to Avoid:** - -1. **Forgetting to mount `.well-known` routes at root** - FastMCP cannot do this automatically when your server is mounted under a path prefix. You must explicitly mount well-known routes at the root level. - -2. **Including mount prefix in both base_url AND mcp_path** - The mount prefix (like `/api`) should only be in `base_url`, not in `mcp_path`. Otherwise you'll get double paths. - - ✅ **Correct:** - ```python - base_url = "http://localhost:8000/api" - mcp_path = "/mcp" - # Result: /api/mcp - ``` - - ❌ **Wrong:** - ```python - base_url = "http://localhost:8000/api" - mcp_path = "/api/mcp" - # Result: /api/api/mcp (double prefix!) - ``` - -Follow the configuration instructions below to set up mounting correctly. -</Warning> - -<Warning> -**CORS Middleware Conflicts:** - -If you're integrating FastMCP into an existing application with its own CORS middleware, be aware that layering CORS middleware can cause conflicts (such as 404 errors on `.well-known` routes or OPTIONS requests). - -FastMCP and the MCP SDK already handle CORS for OAuth routes. If you need CORS on your own application routes, consider using the sub-app pattern: mount FastMCP and your routes as separate apps, each with their own middleware, rather than adding application-wide CORS middleware. -</Warning> - -### Route Types - -OAuth-protected MCP servers expose two categories of routes: - -**Operational routes** handle the OAuth flow and MCP protocol: -- `/authorize` - OAuth authorization endpoint -- `/token` - Token exchange endpoint -- `/auth/callback` - OAuth callback handler -- `/mcp` - MCP protocol endpoint - -**Discovery routes** provide metadata for OAuth clients: -- `/.well-known/oauth-authorization-server` - Authorization server metadata -- `/.well-known/oauth-protected-resource/*` - Protected resource metadata - -When you mount your MCP app under a prefix, operational routes move with it, but discovery routes must stay at root level for RFC compliance. - -### Configuration Parameters - -Three parameters control where routes are located and how they combine: - -**`base_url`** tells clients where to find operational endpoints. This includes any Starlette `Mount()` path prefix (e.g., `/api`): - -```python -base_url="http://localhost:8000/api" # Includes mount prefix -``` - -**`mcp_path`** is the internal FastMCP endpoint path, which gets appended to `base_url`: - -```python -mcp_path="/mcp" # Internal MCP path, NOT the mount prefix -``` - -**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. - -```python -# Usually not needed - just set base_url and it works -issuer_url="http://localhost:8000" # Only if you want root-level discovery -``` - -When `issuer_url` has a path (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`. - -**Key Invariant:** `base_url + mcp_path = actual externally-accessible MCP URL` - -Example: -- `base_url`: `http://localhost:8000/api` (mount prefix `/api`) -- `mcp_path`: `/mcp` (internal path) -- Result: `http://localhost:8000/api/mcp` (final MCP endpoint) - -Note that the mount prefix (`/api` from `Mount("/api", ...)`) goes in `base_url`, while `mcp_path` is just the internal MCP route. Don't include the mount prefix in both places or you'll get `/api/api/mcp`. - -### Mounting Strategy - -When mounting an OAuth-protected server under a path prefix, declare your URLs upfront to make the relationships clear: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider -from starlette.applications import Starlette -from starlette.routing import Mount - -# Define the routing structure -ROOT_URL = "http://localhost:8000" -MOUNT_PREFIX = "/api" -MCP_PATH = "/mcp" -``` - -Create the auth provider with `base_url`: - -```python -auth = GitHubProvider( - client_id="your-client-id", - client_secret="your-client-secret", - base_url=f"{ROOT_URL}{MOUNT_PREFIX}", # Operational endpoints under prefix - # issuer_url defaults to base_url - path-aware discovery works automatically -) -``` - -Create the MCP app, which generates operational routes at the specified path: - -```python -mcp = FastMCP("Protected Server", auth=auth) -mcp_app = mcp.http_app(path=MCP_PATH) -``` - -Retrieve the discovery routes from the auth provider. The `mcp_path` argument should match the path used when creating the MCP app: - -```python -well_known_routes = auth.get_well_known_routes(mcp_path=MCP_PATH) -``` - -Finally, mount everything in the Starlette app with discovery routes at root and the MCP app under the prefix: - -```python -app = Starlette( - routes=[ - *well_known_routes, # Discovery routes at root level - Mount(MOUNT_PREFIX, app=mcp_app), # Operational routes under prefix - ], - lifespan=mcp_app.lifespan, -) -``` - -This configuration produces the following URL structure: - -- MCP endpoint: `http://localhost:8000/api/mcp` -- OAuth authorization: `http://localhost:8000/api/authorize` -- OAuth callback: `http://localhost:8000/api/auth/callback` -- Authorization server metadata: `http://localhost:8000/.well-known/oauth-authorization-server/api` -- Protected resource metadata: `http://localhost:8000/.well-known/oauth-protected-resource/api/mcp` - -Both discovery endpoints use path-aware URLs per RFC 8414 and RFC 9728, matching the `base_url` path. - -### Complete Example - -Here's a complete working example showing all the pieces together: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider -from starlette.applications import Starlette -from starlette.routing import Mount -import uvicorn - -# Define routing structure -ROOT_URL = "http://localhost:8000" -MOUNT_PREFIX = "/api" -MCP_PATH = "/mcp" - -# Create OAuth provider -auth = GitHubProvider( - client_id="your-client-id", - client_secret="your-client-secret", - base_url=f"{ROOT_URL}{MOUNT_PREFIX}", - # issuer_url defaults to base_url - path-aware discovery works automatically -) - -# Create MCP server -mcp = FastMCP("Protected Server", auth=auth) - -@mcp.tool -def analyze(data: str) -> dict: - return {"result": f"Analyzed: {data}"} - -# Create MCP app -mcp_app = mcp.http_app(path=MCP_PATH) - -# Get discovery routes for root level -well_known_routes = auth.get_well_known_routes(mcp_path=MCP_PATH) - -# Assemble the application -app = Starlette( - routes=[ - *well_known_routes, - Mount(MOUNT_PREFIX, app=mcp_app), - ], - lifespan=mcp_app.lifespan, -) - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8000) -``` - -For more details on OAuth authentication, see the [Authentication guide](/servers/auth/authentication). - -## Production Deployment - -### Running with Uvicorn - -When deploying to production, you'll want to optimize your server for performance and reliability. Uvicorn provides several options to improve your server's capabilities: - -```bash -# Run with basic configuration -uvicorn app:app --host 0.0.0.0 --port 8000 - -# Run with multiple workers for production (requires stateless mode - see below) -uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4 -``` - -### Horizontal Scaling - -<VersionBadge version="2.10.2" /> - -When deploying FastMCP behind a load balancer or running multiple server instances, you need to understand how the HTTP transport handles sessions and configure your server appropriately. - -#### Understanding Sessions - -By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions enable stateful MCP features like [elicitation](/servers/elicitation) and [sampling](/servers/sampling), where the server needs to maintain context across multiple requests from the same client. - -This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally. - -#### Without Stateless Mode - -When running multiple server instances behind a load balancer (Traefik, nginx, HAProxy, Kubernetes, etc.), requests from the same client may be routed to different instances: - -1. Client connects to Instance A → session created on Instance A -2. Next request routes to Instance B → session doesn't exist → **request fails** - -You might expect sticky sessions (session affinity) to solve this, but they don't work reliably with MCP clients. - -<Warning> -**Why sticky sessions don't work:** Most MCP clients—including Cursor and Claude Code—use `fetch()` internally and don't properly forward `Set-Cookie` headers. Without cookies, load balancers can't identify which instance should handle subsequent requests. This is a limitation in how these clients implement HTTP, not something you can fix with load balancer configuration. -</Warning> - -#### Enabling Stateless Mode - -For horizontally scaled deployments, enable stateless HTTP mode. In stateless mode, each request creates a fresh transport context, eliminating the need for session affinity entirely. - -**Option 1: Via `http_app()`** - -```python -from fastmcp import FastMCP - -mcp = FastMCP("My Server") - -@mcp.tool -def process(data: str) -> str: - return f"Processed: {data}" - -app = mcp.http_app(stateless_http=True) -``` - -**Option 2: Via `run()`** - -```python -if __name__ == "__main__": - mcp.run(transport="http", stateless_http=True) -``` - -**Option 3: Via environment variable** - -```bash -FASTMCP_STATELESS_HTTP=true uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4 -``` - -### Environment Variables - -Production deployments should never hardcode sensitive information like API keys or authentication tokens. Instead, use environment variables to configure your server at runtime. This keeps your code secure and makes it easy to deploy the same code to different environments with different configurations. - -Here's an example using static token authentication for development (OAuth is recommended for production): - -```python -import os -from fastmcp import FastMCP -from fastmcp.server.auth import StaticTokenVerifier - -# Read configuration from environment -auth_token = os.environ.get("MCP_AUTH_TOKEN") -if auth_token: - auth = StaticTokenVerifier(tokens={auth_token: {"sub": "admin", "client_id": "cli"}}) - mcp = FastMCP("Production Server", auth=auth) -else: - mcp = FastMCP("Production Server") - -app = mcp.http_app() -``` - -Deploy with your secrets safely stored in environment variables: -```bash -MCP_AUTH_TOKEN=secret uvicorn app:app --host 0.0.0.0 --port 8000 -``` - -### OAuth Token Security - -<VersionBadge version="2.13.0" /> - -If you're using the [OAuth Proxy](/servers/auth/oauth-proxy), FastMCP issues its own JWT tokens to clients instead of forwarding upstream provider tokens. This maintains proper OAuth 2.0 token boundaries. - -**Default Behavior (Development Only):** - -By default, FastMCP automatically manages cryptographic keys: -- **Mac/Windows**: Keys are generated and stored in your system keyring, surviving server restarts. Suitable **only** for development and local testing. -- **Linux**: Keys are ephemeral (random salt at startup), so tokens are invalidated on restart. - -This automatic approach is convenient for development but not suitable for production deployments. - -**For Production:** - -Production requires explicit key management to ensure tokens survive restarts and can be shared across multiple server instances. This requires the following two things working together: - -1. **Explicit JWT signing key** for signing tokens issued to clients -3. **Persistent network-accessible storage** for upstream tokens (wrapped in `FernetEncryptionWrapper` to encrypt sensitive data at rest) - -**Configuration:** - -Add two parameters to your auth provider: - -```python {8-12} -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -auth = GitHubProvider( - client_id=os.environ["GITHUB_CLIENT_ID"], - client_secret=os.environ["GITHUB_CLIENT_SECRET"], - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore(host="redis.example.com", port=6379), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ), - base_url="https://your-server.com" # use HTTPS -) -``` - -Both parameters are required for production. Without an explicit signing key, keys are signed using a key derived from the client_secret, which will cause invalidation upon rotation of the client secret. Without persistent storage, tokens are local to the server and won't be trusted across hosts. **Wrap your storage backend in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without encryption, tokens are stored in plaintext. - -For more details on the token architecture and key management, see [OAuth Proxy Key and Storage Management](/servers/auth/oauth-proxy#key-and-storage-management). - -## Reverse Proxy (nginx) - -In production, you'll typically run your FastMCP server behind a reverse proxy like nginx. A reverse proxy provides TLS termination, domain-based routing, static file serving, and an additional layer of security between the internet and your application. - -### Running FastMCP as a Linux Service - -Before configuring nginx, you need your FastMCP server running as a background service. A systemd unit file ensures your server starts automatically and restarts on failure. - -Create a file at `/etc/systemd/system/fastmcp.service`: - -```ini -[Unit] -Description=FastMCP Server -After=network.target - -[Service] -User=www-data -Group=www-data -WorkingDirectory=/opt/fastmcp -ExecStart=/opt/fastmcp/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 -Restart=always -RestartSec=5 -Environment="PATH=/opt/fastmcp/.venv/bin" - -[Install] -WantedBy=multi-user.target -``` - -Enable and start the service: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable fastmcp -sudo systemctl start fastmcp -``` - -This assumes your ASGI application is in `/opt/fastmcp/app.py` with a virtual environment at `/opt/fastmcp/.venv`. Adjust paths to match your deployment layout. - -### nginx Configuration - -FastMCP's Streamable HTTP transport uses Server-Sent Events (SSE) for streaming responses. This requires specific nginx settings to prevent buffering from breaking the event stream. - -Create a site configuration at `/etc/nginx/sites-available/fastmcp`: - -```nginx -server { - listen 80; - server_name mcp.example.com; - - # Redirect HTTP to HTTPS - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl; - server_name mcp.example.com; - - ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem; - - location / { - proxy_pass http://127.0.0.1:8000; - proxy_http_version 1.1; - proxy_set_header Connection ''; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # Required for SSE (Server-Sent Events) streaming - proxy_buffering off; - proxy_cache off; - - # Allow long-lived connections for streaming responses - proxy_read_timeout 300s; - proxy_send_timeout 300s; - } -} -``` - -Enable the site and reload nginx: - -```bash -sudo ln -s /etc/nginx/sites-available/fastmcp /etc/nginx/sites-enabled/ -sudo nginx -t -sudo systemctl reload nginx -``` - -Your FastMCP server is now accessible at `https://mcp.example.com/mcp`. - -<Warning> -**SSE buffering is the most common issue.** If clients connect but never receive streaming responses (progress updates, tool results), verify that `proxy_buffering off` is set. Without it, nginx buffers the entire SSE stream and delivers it only when the connection closes, which breaks real-time communication. -</Warning> - -### Key Considerations - -When deploying FastMCP behind a reverse proxy, keep these points in mind: - -- **Disable buffering**: SSE requires `proxy_buffering off` so events reach clients immediately. This is the single most important setting. -- **Increase timeouts**: The default nginx `proxy_read_timeout` is 60 seconds. Long-running MCP tools will cause the connection to drop. Set timeouts to at least 300 seconds, or higher if your tools run longer. For tools that may exceed any timeout, use [SSE Polling](#sse-polling-for-long-running-operations) to gracefully handle proxy disconnections. -- **Use HTTP/1.1**: Set `proxy_http_version 1.1` and `proxy_set_header Connection ''` to enable keep-alive connections between nginx and your server. Clearing the `Connection` header prevents clients from sending `Connection: close` to your upstream, which would break SSE streams. Both settings are required for proper SSE support. -- **Forward headers**: Pass `X-Forwarded-For` and `X-Forwarded-Proto` so your FastMCP server can determine the real client IP and protocol. This is important for logging and for OAuth redirect URLs. -- **TLS termination**: Let nginx handle TLS certificates (e.g., via Let's Encrypt with Certbot). Your FastMCP server can then run on plain HTTP internally. - -### Mounting Under a Path Prefix - -If you want your MCP server available at a subpath like `https://example.com/api/mcp` instead of at the root domain, adjust the nginx `location` block: - -```nginx -location /api/ { - proxy_pass http://127.0.0.1:8000/; - proxy_http_version 1.1; - proxy_set_header Connection ''; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # Required for SSE streaming - proxy_buffering off; - proxy_cache off; - proxy_read_timeout 300s; - proxy_send_timeout 300s; -} -``` - -Note the trailing `/` on both `location /api/` and `proxy_pass http://127.0.0.1:8000/` — this ensures nginx strips the `/api` prefix before forwarding to your server. If you're using OAuth authentication with a mount prefix, see [Mounting Authenticated Servers](#mounting-authenticated-servers) for additional configuration. - -## Testing Your Deployment - -Once your server is deployed, you'll need to verify it's accessible and functioning correctly. For comprehensive testing strategies including connectivity tests, client testing, and authentication testing, see the [Testing Your Server](/development/tests) guide. - -## Hosting Your Server - -This guide has shown you how to create an HTTP-accessible MCP server, but you'll still need a hosting provider to make it available on the internet. Your FastMCP server can run anywhere that supports Python web applications: - -- **Cloud VMs** (AWS EC2, Google Compute Engine, Azure VMs) -- **Container platforms** (Cloud Run, Container Instances, ECS) -- **Platform-as-a-Service** (Railway, Render, Vercel) -- **Edge platforms** (Cloudflare Workers) -- **Kubernetes clusters** (self-managed or managed) - -The key requirements are Python 3.10+ support and the ability to expose an HTTP port. Most providers will require you to package your server (requirements.txt, Dockerfile, etc.) according to their deployment format. For managed, zero-configuration deployment, see [Prefect Horizon](/deployment/prefect-horizon). diff --git a/docs/v3/deployment/prefect-horizon.mdx b/docs/v3/deployment/prefect-horizon.mdx deleted file mode 100644 index 6a26fa19e..000000000 --- a/docs/v3/deployment/prefect-horizon.mdx +++ /dev/null @@ -1,120 +0,0 @@ ---- -title: Prefect Horizon -sidebarTitle: Prefect Horizon -description: The MCP platform from the FastMCP team -icon: cloud ---- - -[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_horizon&utm_content=v3_guide_intro) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities. - -Horizon includes a **free personal tier for FastMCP users**, making it the fastest way to get a secure, production-ready server URL with built-in OAuth authentication. - -<Info> -Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users. -</Info> - -## The Platform - -Horizon is organized into four integrated pillars: - -- **Deploy**: Managed hosting with CI/CD, scaling, monitoring, and rollbacks. Push code and get a live, governed endpoint in 60 seconds. -- **Registry**: A central catalog of MCP servers across your organization—first-party, third-party, and curated remix servers composed from multiple sources. -- **Gateway**: Role-based access control, authentication, and audit logs. Define what agents can see and do at the tool level. -- **Agents**: A permissioned chat interface for interacting with any MCP server or curated combination of servers. - -This guide focuses on **Horizon Deploy**, the managed hosting layer that gives you the fastest path from a FastMCP server to a production URL. - -## Prerequisites - -To use Horizon, you'll need a [GitHub](https://github.com) account and a GitHub repo containing a FastMCP server. If you don't have one yet, Horizon can create a starter repo for you during onboarding. - -Your repo can be public or private, but must include at least a Python file containing a FastMCP server instance. - -<Tip> -To verify your file is compatible with Horizon, run `fastmcp inspect <file.py:server_object>` to see what Horizon will see when it runs your server. -</Tip> - -If you have a `requirements.txt` or `pyproject.toml` in the repo, Horizon will automatically detect your server's dependencies and install them. Your file *can* have an `if __name__ == "__main__"` block, but it will be ignored by Horizon. - -For example, a minimal server file might look like: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -@mcp.tool -def hello(name: str) -> str: - return f"Hello, {name}!" -``` - -## Getting Started - -There are just three steps to deploying a server to Horizon: - -### Step 1: Select a Repository - -Visit [horizon.prefect.io](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) and sign in with your GitHub account. Connect your GitHub account to grant Horizon access to your repositories, then select the repo you want to deploy. - -<img src="/assets/images/horizon/select-repo.png" alt="Horizon repository selection" /> - -### Step 2: Configure Your Server - -Next, you'll configure how Horizon should build and deploy your server. - -<img src="/assets/images/horizon/configure-server.png" alt="Horizon server configuration" /> - -The configuration screen lets you specify: -- **Server name**: A unique name for your server. This determines your server's URL. -- **Description**: A brief description of what your server does. -- **Entrypoint**: The Python file containing your FastMCP server (e.g., `main.py`). This field has the same syntax as the `fastmcp run` command—use `main.py:mcp` to specify a specific object in the file. -- **Authentication**: When enabled, only authenticated users in your organization can connect. Horizon handles all the OAuth complexity for you. - -Horizon will automatically detect your server's Python dependencies from either a `requirements.txt` or `pyproject.toml` file. - -### Step 3: Deploy and Connect - -Click **Deploy Server** and Horizon will clone your repository, build your server, and deploy it to a unique URL—typically in under 60 seconds. - -<img src="/assets/images/horizon/deployment-live.png" alt="Horizon deployment view showing live server" /> - -Once deployed, your server is accessible at a URL like: - -``` -https://your-server-name.fastmcp.app/mcp -``` - -Horizon monitors your repo and redeploys automatically whenever you push to `main`. It also builds preview deployments for every PR, so you can test changes before they go live. - -## Testing Your Server - -Horizon provides two ways to verify your server is working before connecting external clients. - -### Inspector - -The Inspector gives you a structured view of everything your server exposes—tools, resources, and prompts. You can click any tool, fill in the inputs, execute it, and see the output. This is useful for systematically validating each capability and debugging specific behaviors. - -### ChatMCP - -For quick end-to-end testing, ChatMCP lets you interact with your server conversationally. It uses a fast model optimized for rapid iteration—you can verify the server works, test tool calls in context, and confirm the overall behavior before sharing it with others. - -<img src="/assets/images/horizon/chat.png" alt="Horizon ChatMCP interface" /> - -ChatMCP is designed for testing, not as a daily work environment. Once you've confirmed your server works, you can copy connection snippets for Claude Desktop, Cursor, Claude Code, and other MCP clients—or use the FastMCP client library to connect programmatically. - -## Horizon Agents - -Beyond testing individual servers, Horizon lets you create **Agents**—chat interfaces backed by one or more MCP servers. While ChatMCP tests a single server, Agents let you compose capabilities from multiple servers into a unified experience. - -<img src="/assets/images/horizon/agent-detail.png" alt="Horizon Agent configuration" /> - -To create an agent: -1. Navigate to **Agents** in the sidebar -2. Click **Create Agent** and give it a name and description -3. Add MCP servers to the agent—these can be servers you've deployed to Horizon or external servers in the registry - -Once configured, you can chat with your agent directly in Horizon: - -<img src="/assets/images/horizon/agent-chat.png" alt="Chatting with a Horizon Agent" /> - -Agents are useful for creating purpose-built interfaces that combine tools from different servers. For example, you might create an agent that has access to both your company's internal data server and a general-purpose utilities server. diff --git a/docs/v3/deployment/running-server.mdx b/docs/v3/deployment/running-server.mdx deleted file mode 100644 index c10855345..000000000 --- a/docs/v3/deployment/running-server.mdx +++ /dev/null @@ -1,286 +0,0 @@ ---- -title: Running Your Server -sidebarTitle: Running Your Server -description: Learn how to run your FastMCP server locally for development and testing -icon: circle-play ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -FastMCP servers can be run in different ways depending on your needs. This guide focuses on running servers locally for development and testing. For production deployment to a URL, see the [HTTP Deployment](/deployment/http) guide. - -## The `run()` Method - -Every FastMCP server needs to be started to accept connections. The simplest way to run a server is by calling the `run()` method on your FastMCP instance. This method starts the server and blocks until it's stopped, handling all the connection management for you. - -<Tip> -For maximum compatibility, it's best practice to place the `run()` call within an `if __name__ == "__main__":` block. This ensures the server starts only when the script is executed directly, not when imported as a module. -</Tip> - -```python {9-10} my_server.py -from fastmcp import FastMCP - -mcp = FastMCP(name="MyServer") - -@mcp.tool -def hello(name: str) -> str: - return f"Hello, {name}!" - -if __name__ == "__main__": - mcp.run() -``` - -You can now run this MCP server by executing `python my_server.py`. - -## Transport Protocols - -MCP servers communicate with clients through different transport protocols. Think of transports as the "language" your server speaks to communicate with clients. FastMCP supports three main transport protocols, each designed for specific use cases and deployment scenarios. - -The choice of transport determines how clients connect to your server, what network capabilities are available, and how many clients can connect simultaneously. Understanding these transports helps you choose the right approach for your application. - -### STDIO Transport (Default) - -STDIO (Standard Input/Output) is the default transport for FastMCP servers. When you call `run()` without arguments, your server uses STDIO transport. This transport communicates through standard input and output streams, making it perfect for command-line tools and desktop applications like Claude Desktop. - -With STDIO transport, the client spawns a new server process for each session and manages its lifecycle. The server reads MCP messages from stdin and writes responses to stdout. This is why STDIO servers don't stay running - they're started on-demand by the client. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -@mcp.tool -def hello(name: str) -> str: - return f"Hello, {name}!" - -if __name__ == "__main__": - mcp.run() # Uses STDIO transport by default -``` - -STDIO is ideal for: -- Local development and testing -- Claude Desktop integration -- Command-line tools -- Single-user applications - -### HTTP Transport (Streamable) - -HTTP transport turns your MCP server into a web service accessible via a URL. This transport uses the Streamable HTTP protocol, which allows clients to connect over the network. Unlike STDIO where each client gets its own process, an HTTP server can handle multiple clients simultaneously. - -The Streamable HTTP protocol provides full bidirectional communication between client and server, supporting all MCP operations including streaming responses. This makes it the recommended choice for network-based deployments. - -To use HTTP transport, specify it in the `run()` method along with networking options: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -@mcp.tool -def hello(name: str) -> str: - return f"Hello, {name}!" - -if __name__ == "__main__": - # Start an HTTP server on port 8000 - mcp.run(transport="http", host="127.0.0.1", port=8000) -``` - -Your server is now accessible at `http://localhost:8000/mcp`. This URL is the MCP endpoint that clients will connect to. HTTP transport enables: -- Network accessibility -- Multiple concurrent clients -- Integration with web infrastructure -- Remote deployment capabilities - -For production HTTP deployment with authentication and advanced configuration, see the [HTTP Deployment](/deployment/http) guide. - -### SSE Transport (Legacy) - -Server-Sent Events (SSE) transport was the original HTTP-based transport for MCP. While still supported for backward compatibility, it has limitations compared to the newer Streamable HTTP transport. SSE only supports server-to-client streaming, making it less efficient for bidirectional communication. - -```python -if __name__ == "__main__": - # SSE transport - use HTTP instead for new projects - mcp.run(transport="sse", host="127.0.0.1", port=8000) -``` - -We recommend using HTTP transport instead of SSE for all new projects. SSE remains available only for compatibility with older clients that haven't upgraded to Streamable HTTP. - -### Choosing the Right Transport - -Each transport serves different needs. STDIO is perfect when you need simple, local execution - it's what Claude Desktop and most command-line tools expect. HTTP transport is essential when you need network access, want to serve multiple clients, or plan to deploy your server remotely. SSE exists only for backward compatibility and shouldn't be used in new projects. - -Consider your deployment scenario: Are you building a tool for local use? STDIO is your best choice. Need a centralized service that multiple clients can access? HTTP transport is the way to go. - -## The FastMCP CLI - -FastMCP provides a powerful command-line interface for running servers without modifying the source code. The CLI can automatically find and run your server with different transports, manage dependencies, and handle development workflows: - -```bash -fastmcp run server.py -``` - -The CLI automatically finds a FastMCP instance in your file (named `mcp`, `server`, or `app`) and runs it with the specified options. This is particularly useful for testing different transports or configurations without changing your code. - -### Dependency Management - -The CLI integrates with `uv` to manage Python environments and dependencies: - -```bash -# Run with a specific Python version -fastmcp run server.py --python 3.11 - -# Run with additional packages -fastmcp run server.py --with pandas --with numpy - -# Run with dependencies from a requirements file -fastmcp run server.py --with-requirements requirements.txt - -# Combine multiple options -fastmcp run server.py --python 3.10 --with httpx --transport http - -# Run within a specific project directory -fastmcp run server.py --project /path/to/project -``` - -<Note> -When using `--python`, `--with`, `--project`, or `--with-requirements`, the server runs via `uv run` subprocess instead of using your local environment. -</Note> - -### Passing Arguments to Servers - -When servers accept command line arguments (using argparse, click, or other libraries), you can pass them after `--`: - -```bash -fastmcp run config_server.py -- --config config.json -fastmcp run database_server.py -- --database-path /tmp/db.sqlite --debug -``` - -This is useful for servers that need configuration files, database paths, API keys, or other runtime options. - -For more CLI features including development mode with the MCP Inspector, see the [CLI documentation](/cli/running). - -### Auto-Reload for Development - -<VersionBadge version="3.0.0" /> - -During development, you can use the `--reload` flag to automatically restart your server when source files change: - -```bash -fastmcp run server.py --reload -``` - -The server watches for changes to Python files in the current directory and restarts automatically when you save changes. This provides a fast feedback loop during development without manually stopping and starting the server. - -```bash -# Watch specific directories for changes -fastmcp run server.py --reload --reload-dir ./src --reload-dir ./lib - -# Combine with other options -fastmcp run server.py --reload --transport http --port 8080 -``` - -<Note> -Auto-reload uses stateless mode to enable seamless restarts. For stdio transport, this is fully featured. For HTTP transport, some bidirectional features like elicitation are not available during reload mode. -</Note> - -SSE transport does not support auto-reload due to session limitations. Use HTTP transport instead if you need both network access and auto-reload. - -### Async Usage - -FastMCP servers are built on async Python, but the framework provides both synchronous and asynchronous APIs to fit your application's needs. The `run()` method we've been using is actually a synchronous wrapper around the async server implementation. - -For applications that are already running in an async context, FastMCP provides the `run_async()` method: - -```python {10-12} -from fastmcp import FastMCP -import asyncio - -mcp = FastMCP(name="MyServer") - -@mcp.tool -def hello(name: str) -> str: - return f"Hello, {name}!" - -async def main(): - # Use run_async() in async contexts - await mcp.run_async(transport="http", port=8000) - -if __name__ == "__main__": - asyncio.run(main()) -``` - -<Warning> -The `run()` method cannot be called from inside an async function because it creates its own async event loop internally. If you attempt to call `run()` from inside an async function, you'll get an error about the event loop already running. - -Always use `run_async()` inside async functions and `run()` in synchronous contexts. -</Warning> - -Both `run()` and `run_async()` accept the same transport arguments, so all the examples above apply to both methods. - -## Custom Routes - -When using HTTP transport, you might want to add custom web endpoints alongside your MCP server. This is useful for health checks, status pages, or simple APIs. FastMCP lets you add custom routes using the `@custom_route` decorator: - -```python -from fastmcp import FastMCP -from starlette.requests import Request -from starlette.responses import PlainTextResponse - -mcp = FastMCP("MyServer") - -@mcp.custom_route("/health", methods=["GET"]) -async def health_check(request: Request) -> PlainTextResponse: - return PlainTextResponse("OK") - -@mcp.tool -def process(data: str) -> str: - return f"Processed: {data}" - -if __name__ == "__main__": - mcp.run(transport="http") # Health check at http://localhost:8000/health -``` - -Custom routes are served by the same web server as your MCP endpoint. They're available at the root of your domain while the MCP endpoint is at `/mcp/`. For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). - -## Alternative Initialization Patterns - -The `if __name__ == "__main__"` pattern works well for standalone scripts, but some deployment scenarios require different approaches. FastMCP handles these cases automatically. - -### CLI-Only Servers - -When using the FastMCP CLI, you don't need the `if __name__` block at all. The CLI will find your FastMCP instance and run it: - -```python -# server.py -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") # CLI looks for 'mcp', 'server', or 'app' - -@mcp.tool -def process(data: str) -> str: - return f"Processed: {data}" - -# No if __name__ block needed - CLI will find and run 'mcp' -``` - -### ASGI Applications - -For ASGI deployment (running with Uvicorn or similar), you'll want to create an ASGI application object. This approach is common in production deployments where you need more control over the server configuration: - -```python -# app.py -from fastmcp import FastMCP - -def create_app(): - mcp = FastMCP("MyServer") - - @mcp.tool - def process(data: str) -> str: - return f"Processed: {data}" - - return mcp.http_app() - -app = create_app() # Uvicorn will use this -``` - -See the [HTTP Deployment](/deployment/http) guide for more ASGI deployment patterns. \ No newline at end of file diff --git a/docs/v3/deployment/sandboxed-agents.mdx b/docs/v3/deployment/sandboxed-agents.mdx deleted file mode 100644 index 16191affb..000000000 --- a/docs/v3/deployment/sandboxed-agents.mdx +++ /dev/null @@ -1,262 +0,0 @@ ---- -title: Sandboxed Agents -sidebarTitle: Sandboxed Agents -description: Expose MCP tools to isolated agents without giving the sandbox long-lived credentials. -icon: box-open ---- - -This guide is for deployments where an agent runs inside an isolated container, subprocess, or remote worker and still needs MCP access. In that setup, the sandbox itself becomes part of your trust boundary. - -The core recommendation is simple: use FastMCP as the capability boundary. Run a remote FastMCP server, authenticate the sandbox with short-lived scoped credentials, and keep privileged credentials on the server side. - -## When to Use This Pattern - -This pattern is useful when: - -- your agent runs in an ephemeral container or subprocess -- you do not want long-lived credentials inside that sandbox -- you need per-run, per-tenant, or per-job scoping -- the sandbox must call internal APIs, databases, or upstream MCP servers indirectly - -If you are building a local desktop integration, STDIO and normal local configuration may be enough. This guide is for cases where the sandbox is isolated enough that secret distribution, credential lifetimes, and privilege boundaries become part of the design. - -## What Changes in a Sandboxed Deployment - -A desktop MCP client usually runs on a developer's machine and launches local servers with configuration the developer controls. A sandboxed agent is different: - -- It often runs in an ephemeral container or subprocess. -- Its filesystem may be inspected after the fact. -- Its environment variables may be broader than you intend. -- You may launch many sandboxes concurrently for different users, tenants, or jobs. - -That means convenience patterns that are acceptable locally become risky in sandboxes. Passing a GitHub token, database password, or cloud credentials directly into the sandbox creates a secret distribution problem you do not need to have. - -The safer approach is to make your FastMCP server the only component with privileged access and let the sandbox call it over MCP. - -## Recommended Architecture - -Use this shape by default: - -```mermaid -flowchart LR - A["Sandboxed agent"] -->|"short-lived token"| B["FastMCP server"] - B --> C["internal APIs"] - B --> D["databases"] - B --> E["other MCP servers"] -``` - -The sandbox gets: - -- the MCP server URL -- a short-lived token scoped to its job, tenant, or run -- no long-lived upstream credentials - -The FastMCP server does the privileged work: - -- verifies the sandbox token -- authorizes the request from token claims, scopes, or other server-side policy -- exposes only the tools that sandbox should see -- talks to internal APIs, databases, or upstream MCP servers on the sandbox's behalf - -The key design rule is simple: - -<Tip> -Give the sandbox capabilities, not credentials. -</Tip> - -With that boundary in place, the next questions are how the sandbox connects, how the server verifies and authorizes it, and how you design the tools the sandbox is allowed to call. - -## Prefer HTTP for Sandboxed Agents - -For sandboxes, prefer a remote HTTP server over a local STDIO server. - -STDIO is still excellent for local development, but a remote HTTP server is usually the better production boundary for sandboxed agents because: - -- authentication is explicit -- the server lifecycle is independent from the sandbox lifecycle -- secrets stay on the server -- one deployment can safely serve many sandboxes -- auditing and revocation happen in one place - -This means the sandbox should connect as a client: - -```python -from fastmcp import Client -from fastmcp.client.auth import BearerAuth - -client = Client( - "https://sandbox-tools.example.com/mcp", - auth=BearerAuth("short-lived-sandbox-token"), -) -``` - -And your FastMCP server should run remotely: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("Sandbox Tools") - -if __name__ == "__main__": - mcp.run(transport="http", host="0.0.0.0", port=8000) -``` - -For production transport setup, see [HTTP Deployment](/deployment/http). - -## Use Short-Lived, Scoped Credentials - -For sandboxed agents, it is usually cleaner to issue credentials for the sandbox session than to place long-lived upstream credentials directly inside the container. - -In practice, that usually means issuing a short-lived bearer token for each sandbox, run, or tenant and validating it on your FastMCP server with a token verifier. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.jwt import JWTVerifier - -auth = JWTVerifier( - jwks_uri="https://auth.example.com/.well-known/jwks.json", - issuer="https://auth.example.com", - audience="sandbox-mcp", -) - -mcp = FastMCP("Sandbox Tools", auth=auth) -``` - -The token should identify the sandbox's scope. Depending on your system, it may represent a job, a tenant, a run, or a user-authorized session. Useful claims often include: - -- sandbox or run id -- tenant or installation id -- user or actor id when applicable -- expiration -- optional capability scopes - -Avoid shared static tokens across many sandboxes. If one sandbox token leaks, you want the blast radius to be small and the lifetime to be short. - -Token verification is only one half of the boundary. Authorization still belongs on the FastMCP server: use scopes, claims, middleware, or custom auth checks to decide which tools and resources that sandbox can actually access. - -For example, you can verify the token globally and still require a narrower scope on a specific tool: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_scopes -from fastmcp.server.auth.providers.jwt import JWTVerifier - -auth = JWTVerifier( - jwks_uri="https://auth.example.com/.well-known/jwks.json", - issuer="https://auth.example.com", - audience="sandbox-mcp", -) - -mcp = FastMCP("Sandbox Tools", auth=auth) - -@mcp.tool(auth=require_scopes("write:summary")) -def write_summary(content: str) -> str: - return f"Stored summary with {len(content)} characters" -``` - -For validation patterns, see [Token Verification](/servers/auth/token-verification). For policy enforcement, see [Authorization](/servers/authorization). - -## Expose Capabilities, Not Raw Access - -The sandbox should not need: - -- GitHub app private keys -- database passwords -- upstream OAuth client secrets -- cloud provider credentials - -Instead, expose MCP tools that perform privileged work on the server side. - -Good sandbox-facing tools tend to look like this: - -- `get_recent_updates` -- `write_summary` -- `fetch_repo_context` -- `publish_review_comment` - -These tools describe the capability the sandbox needs, not the low-level credentialed action required to perform it. - -That distinction matters. A tool like `write_summary` lets the server decide where and how to persist the summary. A tool like `run_sql` or `call_internal_api` pushes privilege and policy into the sandbox where they are much harder to control. - -Sandboxed agents behave best when those tools are narrow and structured: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("Sandbox Tools") - -@mcp.tool -def write_summary(content: str) -> str: - """Store the final summary for the current run.""" - return f"Stored summary with {len(content)} characters" - -@mcp.tool -def publish_review_comment(pr_number: int, body: str) -> str: - """Queue a review comment for a specific pull request.""" - return f"Queued comment for PR #{pr_number}" -``` - -These are easier to audit, easier to authorize, and easier for agents to use reliably than a broad catch-all tool like `mutate_state(kind: str, payload: dict)`. - -Narrow tools also let you express different policies per tool instead of creating one large privileged escape hatch. - -## Use a Proxy When Upstream Systems Are More Privileged - -If the sandbox needs access to other MCP servers or internal systems, put FastMCP in front of them instead of forwarding secrets into the sandbox. - -This is where proxying becomes useful. Your public-facing FastMCP server can authenticate the sandbox, then forward allowed capabilities to upstream systems with stronger credentials. - -Typical examples: - -- a sandbox-safe MCP gateway in front of internal MCP servers -- a FastMCP layer in front of internal HTTP APIs -- a job-scoped server that fronts a Git provider, issue tracker, or storage system - -If the upstream system is itself an MCP server, FastMCP's proxy support is a natural fit. See [MCP Proxy](/servers/providers/proxy). - -## mcp.json for Sandboxed Clients - -If your sandboxed agent is configured through `mcp.json`, keep that configuration minimal. Point it at the remote FastMCP server and pass only the values the sandbox actually needs. - -```json -{ - "mcpServers": { - "sandbox-tools": { - "url": "https://sandbox-tools.example.com/mcp", - "transport": "http" - } - } -} -``` - -In many systems, authentication is injected by the launcher or environment rather than hardcoded in `mcp.json`. That is usually the right tradeoff for sandboxes. Avoid baking long-lived credentials directly into generated config files, and avoid treating `mcp.json` as the place where secret material should live. - -That is all this section needs to do: tell the sandbox where the server lives. Keep auth and secret handling elsewhere. - -For configuration details, see [MCP.json](/integrations/mcp-json-configuration). - -## Common Mistakes - -The same few mistakes show up again and again in sandboxed deployments: - -- passing long-lived API keys directly into the sandbox -- treating helper scripts in the sandbox as a security boundary -- exposing broad mutation tools instead of narrow capabilities -- using one shared token for every sandbox -- relying on STDIO inheritance for configuration in production - -Each of these works at first. Each becomes painful once you have multiple tenants, multiple jobs, or an incident that requires revoking access quickly. - -## Production Checklist - -Before shipping a sandbox-facing FastMCP server, check these: - -- The sandbox connects over HTTP, not with privileged local credentials. -- Tokens are short-lived and scoped to a run, tenant, or job. -- The FastMCP server verifies tokens on every request. -- Long-lived secrets stay on the server side. -- Tools are narrow, explicit, and structured. -- Upstream privileged systems sit behind the FastMCP server or proxy. -- Revocation and audit live at the server boundary, not inside the sandbox. - -If you adopt those defaults, sandbox support stops being a special case and becomes a normal deployment pattern: isolated workers talk to a constrained FastMCP surface, and the server handles the privileged parts centrally. diff --git a/docs/v3/deployment/server-configuration.mdx b/docs/v3/deployment/server-configuration.mdx deleted file mode 100644 index f9b0e4781..000000000 --- a/docs/v3/deployment/server-configuration.mdx +++ /dev/null @@ -1,640 +0,0 @@ ---- -title: "Project Configuration" -sidebarTitle: "Project Configuration" -description: Use fastmcp.json for portable, declarative project configuration -icon: file-code ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.12.0" /> - -FastMCP supports declarative configuration through `fastmcp.json` files. This is the canonical and preferred way to configure FastMCP projects, providing a single source of truth for server settings, dependencies, and deployment options that replaces complex command-line arguments. - -The `fastmcp.json` file is designed to be a portable description of your server configuration that can be shared across environments and teams. When running from a `fastmcp.json` file, you can override any configuration values using CLI arguments. - -## Overview - -The `fastmcp.json` configuration file allows you to define all aspects of your FastMCP server in a structured, shareable format. Instead of remembering command-line arguments or writing shell scripts, you declare your server's configuration once and use it everywhere. - -When you have a `fastmcp.json` file, running your server becomes as simple as: - -```bash -# Run the server using the configuration -fastmcp run fastmcp.json - -# Or if fastmcp.json exists in the current directory -fastmcp run -``` - -This configuration approach ensures reproducible deployments across different environments, from local development to production servers. It works seamlessly with Claude Desktop, VS Code extensions, and any MCP-compatible client. - -## File Structure - -The `fastmcp.json` configuration answers three fundamental questions about your server: - -- **Source** = WHERE does your server code live? -- **Environment** = WHAT environment setup does it require? -- **Deployment** = HOW should the server run? - -This conceptual model helps you understand the purpose of each configuration section and organize your settings effectively. The configuration file maps directly to these three concerns: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - // WHERE: Location of your server code - "type": "filesystem", // Optional, defaults to "filesystem" - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - // WHAT: Environment setup and dependencies - "type": "uv", // Optional, defaults to "uv" - "python": ">=3.10", - "dependencies": ["pandas", "numpy"] - }, - "deployment": { - // HOW: Runtime configuration - "transport": "stdio", - "log_level": "INFO" - } -} -``` - -Only the `source` field is required. The `environment` and `deployment` sections are optional and provide additional configuration when needed. - -### JSON Schema Support - -FastMCP provides JSON schemas for IDE autocomplete and validation. Add the schema reference to your `fastmcp.json` for enhanced developer experience: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - } -} -``` - -Two schema URLs are available: -- **Version-specific**: `https://gofastmcp.com/public/schemas/fastmcp.json/v1.json` -- **Latest version**: `https://gofastmcp.com/public/schemas/fastmcp.json/latest.json` - -Modern IDEs like VS Code will automatically provide autocomplete suggestions, validation, and inline documentation when the schema is specified. - -### Source Configuration - -The source configuration determines **WHERE** your server code lives. It tells FastMCP how to find and load your server, whether it's a local Python file, a remote repository, or hosted in the cloud. This section is required and forms the foundation of your configuration. - -<Card icon="code" title="Source"> -<ParamField body="source" type="object" required> - The server source configuration that determines where your server code lives. - - <ParamField body="type" type="string" default="filesystem"> - The source type identifier that determines which implementation to use. Currently supports `"filesystem"` for local files. Future releases will add support for `"git"` and `"cloud"` source types. - </ParamField> - - <Expandable title="FileSystemSource"> - When `type` is `"filesystem"` (or omitted), the source points to a local Python file containing your FastMCP server: - - <ParamField body="path" type="string" required> - Path to the Python file containing your FastMCP server. - </ParamField> - - <ParamField body="entrypoint" type="string"> - Name of the server instance or factory function within the module: - - Can be a FastMCP server instance (e.g., `mcp = FastMCP("MyServer")`) - - Can be a function with no arguments that returns a FastMCP server - - If not specified, FastMCP searches for common names: `mcp`, `server`, or `app` - </ParamField> - - **Example:** - ```json - "source": { - "type": "filesystem", - "path": "src/server.py", - "entrypoint": "mcp" - } - ``` - - Note: File paths are resolved relative to the configuration file's location. - </Expandable> -</ParamField> -</Card> - -<Note> -**Future Source Types** - -Future releases will support additional source types: -- **Git repositories** (`type: "git"`) for loading server code directly from version control -- **Prefect Horizon** (`type: "cloud"`) for hosted servers with automatic scaling and management -</Note> - -### Environment Configuration - -The environment configuration determines **WHAT** environment setup your server requires. It controls the build-time setup of your Python environment, ensuring your server runs with the exact Python version and dependencies it requires. This section creates isolated, reproducible environments across different systems. - -FastMCP uses an extensible environment system with a base `Environment` class that can be implemented by different environment providers. Currently, FastMCP supports the `UVEnvironment` for Python environment management using `uv`'s powerful dependency resolver. - -<Card icon="code" title="Environment"> -<ParamField body="environment" type="object"> - Optional environment configuration. When specified, FastMCP uses the appropriate environment implementation to set up your server's runtime. - - <ParamField body="type" type="string" default="uv"> - The environment type identifier that determines which implementation to use. Currently supports `"uv"` for Python environments managed by uv. If omitted, defaults to `"uv"`. - </ParamField> - - <Expandable title="UVEnvironment"> - When `type` is `"uv"` (or omitted), the environment uses uv to manage Python dependencies: - - <ParamField body="python" type="string"> - Python version constraint. Examples: - - Exact version: `"3.12"` - - Minimum version: `">=3.10"` - - Version range: `">=3.10,<3.13"` - </ParamField> - - <ParamField body="dependencies" type="list[str]"> - List of pip packages with optional version specifiers (PEP 508 format). - ```json - "dependencies": ["pandas>=2.0", "requests", "httpx"] - ``` - </ParamField> - - <ParamField body="requirements" type="string"> - Path to a requirements.txt file, resolved relative to the config file location. - ```json - "requirements": "requirements.txt" - ``` - </ParamField> - - <ParamField body="project" type="string"> - Path to a project directory containing pyproject.toml for uv project management. - ```json - "project": "." - ``` - </ParamField> - - <ParamField body="editable" type="list[string]"> - List of paths to packages to install in editable/development mode. Useful for local development when you want changes to be reflected immediately. Supports multiple packages for monorepo setups or shared libraries. - ```json - "editable": ["."] - ``` - Or with multiple packages: - ```json - "editable": [".", "../shared-lib", "/path/to/another-package"] - ``` - </ParamField> - - **Example:** - ```json - "environment": { - "type": "uv", - "python": ">=3.10", - "dependencies": ["pandas", "numpy"], - "editable": ["."] - } - ``` - - Note: When any UVEnvironment field is specified, FastMCP automatically creates an isolated environment using `uv` before running your server. - </Expandable> -</ParamField> -</Card> - -When environment configuration is provided, FastMCP: -1. Detects the environment type (defaults to `"uv"` if not specified) -2. Creates an isolated environment using the appropriate provider -3. Installs the specified dependencies -4. Runs your server in this clean environment - -This build-time setup ensures your server always has the dependencies it needs, without polluting your system Python or conflicting with other projects. - -<Note> -**Future Environment Types** - -Similar to source types, future releases may support additional environment types for different runtime requirements, such as Docker containers or language-specific environments beyond Python. -</Note> - -### Deployment Configuration - -The deployment configuration controls **HOW** your server runs. It defines the runtime behavior including network settings, environment variables, and execution context. These settings determine how your server operates when it executes, from transport protocols to logging levels. - -Environment variables are included in this section because they're runtime configuration that affects how your server behaves when it executes, not how its environment is built. The deployment configuration is applied every time your server starts, controlling its operational characteristics. - -<Card icon="code" title="Deployment Fields"> -<ParamField body="deployment" type="object"> - Optional runtime configuration for the server. - - <Expandable title="Deployment Fields"> - <ParamField body="transport" type="string" default="stdio"> - Protocol for client communication: - - `"stdio"`: Standard input/output for desktop clients - - `"http"`: Network-accessible HTTP server - - `"sse"`: Server-sent events - </ParamField> - - <ParamField body="host" type="string" default="127.0.0.1"> - Network interface to bind (HTTP transport only): - - `"127.0.0.1"`: Local connections only - - `"0.0.0.0"`: All network interfaces - </ParamField> - - <ParamField body="port" type="integer" default="3000"> - Port number for HTTP transport. - </ParamField> - - <ParamField body="path" type="string" default="/mcp/"> - URL path for the MCP endpoint when using HTTP transport. - </ParamField> - - <ParamField body="log_level" type="string" default="INFO"> - Server logging verbosity. Options: - - `"DEBUG"`: Detailed debugging information - - `"INFO"`: General informational messages - - `"WARNING"`: Warning messages - - `"ERROR"`: Error messages only - - `"CRITICAL"`: Critical errors only - </ParamField> - - <ParamField body="env" type="object"> - Environment variables to set when running the server. Supports `${VAR_NAME}` syntax for runtime interpolation. - ```json - "env": { - "API_KEY": "secret-key", - "DATABASE_URL": "postgres://${DB_USER}@${DB_HOST}/mydb" - } - ``` - </ParamField> - - <ParamField body="cwd" type="string"> - Working directory for the server process. Relative paths are resolved from the config file location. - </ParamField> - - <ParamField body="args" type="list[str]"> - Command-line arguments to pass to the server, passed after `--` to the server's argument parser. - ```json - "args": ["--config", "server-config.json"] - ``` - </ParamField> - </Expandable> -</ParamField> -</Card> - -#### Environment Variable Interpolation - -The `env` field in deployment configuration supports runtime interpolation of environment variables using `${VAR_NAME}` syntax. This enables dynamic configuration based on your deployment environment: - -```json -{ - "deployment": { - "env": { - "API_URL": "https://api.${ENVIRONMENT}.example.com", - "DATABASE_URL": "postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/myapp", - "CACHE_KEY": "myapp_${ENVIRONMENT}_${VERSION}" - } - } -} -``` - -When the server starts, FastMCP replaces `${ENVIRONMENT}`, `${DB_USER}`, etc. with values from your system's environment variables. If a variable doesn't exist, the placeholder is preserved as-is. - -**Example**: If your system has `ENVIRONMENT=production` and `DB_HOST=db.example.com`: -```json -// Configuration -{ - "deployment": { - "env": { - "API_URL": "https://api.${ENVIRONMENT}.example.com", - "DB_HOST": "${DB_HOST}" - } - } -} - -// Result at runtime -{ - "API_URL": "https://api.production.example.com", - "DB_HOST": "db.example.com" -} -``` - -This feature is particularly useful for: -- Deploying the same configuration across development, staging, and production -- Keeping sensitive values out of configuration files -- Building dynamic URLs and connection strings -- Creating environment-specific prefixes or suffixes - -## Usage with CLI Commands - -FastMCP automatically detects and uses a file specifically named `fastmcp.json` in the current directory, making server execution simple and consistent. Files with FastMCP configuration format but different names are not auto-detected and must be specified explicitly: - -```bash -# Auto-detect fastmcp.json in current directory -cd my-project -fastmcp run # No arguments needed! - -# Or specify a configuration file explicitly -fastmcp run prod.fastmcp.json - -# Skip environment setup when already in a uv environment -fastmcp run fastmcp.json --skip-env - -# Skip source preparation when source is already prepared -fastmcp run fastmcp.json --skip-source - -# Skip both environment and source preparation -fastmcp run fastmcp.json --skip-env --skip-source -``` - -### Pre-building Environments - -You can use `fastmcp project prepare` to create a persistent uv project with all dependencies pre-installed: - -```bash -# Create a persistent environment -fastmcp project prepare fastmcp.json --output-dir ./env - -# Use the pre-built environment to run the server -fastmcp run fastmcp.json --project ./env -``` - -This pattern separates environment setup (slow) from server execution (fast), useful for deployment scenarios. - -### Using an Existing Environment - -By default, FastMCP creates an isolated environment with `uv` based on your configuration. When you already have a suitable Python environment, use the `--skip-env` flag to skip environment creation: - -```bash -fastmcp run fastmcp.json --skip-env -``` - -**When you already have an environment:** -- You're in an activated virtual environment with all dependencies installed -- You're inside a Docker container with pre-installed dependencies -- You're in a CI/CD pipeline that pre-builds the environment -- You're using a system-wide installation with all required packages -- You're in a uv-managed environment (prevents infinite recursion) - -This flag tells FastMCP: "I already have everything installed, just run the server." - -### Using an Existing Source - -When working with source types that require preparation (future support for git repositories or cloud sources), use the `--skip-source` flag when you already have the source code available: - -```bash -fastmcp run fastmcp.json --skip-source -``` - -**When you already have the source:** -- You've previously cloned a git repository and don't need to re-fetch -- You have a cached copy of a cloud-hosted server -- You're in a CI/CD pipeline where source checkout is a separate step -- You're iterating locally on already-downloaded code - -This flag tells FastMCP: "I already have the source code, skip any download/clone steps." - -Note: For filesystem sources (local Python files), this flag has no effect since they don't require preparation. - -The configuration file works with all FastMCP commands: -- **`run`** - Start the server in production mode -- **`dev`** - Launch with the Inspector UI for development -- **`inspect`** - View server capabilities and configuration -- **`install`** - Install to Claude Desktop, Cursor, or other MCP clients - -When no file argument is provided, FastMCP searches the current directory for `fastmcp.json`. This means you can simply navigate to your project directory and run `fastmcp run` to start your server with all its configured settings. - -### CLI Override Behavior - -Command-line arguments take precedence over configuration file values, allowing ad-hoc adjustments without modifying the file: - -```bash -# Config specifies port 3000, CLI overrides to 8080 -fastmcp run fastmcp.json --port 8080 - -# Config specifies stdio, CLI overrides to HTTP -fastmcp run fastmcp.json --transport http - -# Add extra dependencies not in config -fastmcp run fastmcp.json --with requests --with httpx -``` - -This precedence order enables: -- Quick testing of different settings -- Environment-specific overrides in deployment scripts -- Debugging with increased log levels -- Temporary configuration changes - -### Custom Naming Patterns - -You can use different configuration files for different environments: - -- `fastmcp.json` - Default configuration -- `dev.fastmcp.json` - Development settings -- `prod.fastmcp.json` - Production settings -- `test_fastmcp.json` - Test configuration - -Any file with "fastmcp.json" in the name is recognized as a configuration file. - -## Examples - -<Tabs> -<Tab title="Basic Configuration"> - -A minimal configuration for a simple server: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - } -} -``` -This configuration explicitly specifies the server entrypoint (`mcp`), making it clear which server instance or factory function to use. Uses all defaults: STDIO transport, no special dependencies, standard logging. -</Tab> -<Tab title="Development Configuration"> - -A configuration optimized for local development: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - // WHERE does the server live? - "source": { - "path": "src/server.py", - "entrypoint": "app" - }, - // WHAT dependencies does it need? - "environment": { - "type": "uv", - "python": "3.12", - "dependencies": ["fastmcp[dev]"], - "editable": "." - }, - // HOW should it run? - "deployment": { - "transport": "http", - "host": "127.0.0.1", - "port": 8000, - "log_level": "DEBUG", - "env": { - "DEBUG": "true", - "ENV": "development" - } - } -} -``` -</Tab> -<Tab title="Production Configuration"> - -A production-ready configuration with full dependency management: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - // WHERE does the server live? - "source": { - "path": "app/main.py", - "entrypoint": "mcp_server" - }, - // WHAT dependencies does it need? - "environment": { - "python": "3.11", - "requirements": "requirements/production.txt", - "project": "." - }, - // HOW should it run? - "deployment": { - "transport": "http", - "host": "0.0.0.0", - "port": 3000, - "path": "/api/mcp/", - "log_level": "INFO", - "env": { - "ENV": "production", - "API_BASE_URL": "https://api.example.com", - "DATABASE_URL": "postgresql://user:pass@db.example.com/prod" - }, - "cwd": "/app", - "args": ["--workers", "4"] - } -} -``` -</Tab> -<Tab title="Data Science Server"> - -Configuration for a data analysis server with scientific packages: - -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "analysis_server.py", - "entrypoint": "mcp" - }, - "environment": { - "python": "3.11", - "dependencies": [ - "pandas>=2.0", - "numpy", - "scikit-learn", - "matplotlib", - "jupyterlab" - ] - }, - "deployment": { - "transport": "stdio", - "env": { - "MATPLOTLIB_BACKEND": "Agg", - "DATA_PATH": "./datasets" - } - } -} -``` -</Tab> -<Tab title="Multi-Environment Setup"> - -You can maintain multiple configuration files for different environments: - -**dev.fastmcp.json**: -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "deployment": { - "transport": "http", - "log_level": "DEBUG" - } -} -``` - -**prod.fastmcp.json**: -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - "requirements": "requirements/production.txt" - }, - "deployment": { - "transport": "http", - "host": "0.0.0.0", - "log_level": "WARNING" - } -} -``` - -Run different configurations: -```bash -fastmcp run dev.fastmcp.json # Development -fastmcp run prod.fastmcp.json # Production -``` -</Tab> -</Tabs> - -## Migrating from CLI Arguments - -If you're currently using command-line arguments or shell scripts, migrating to `fastmcp.json` simplifies your workflow. Here's how common CLI patterns map to configuration: - -**CLI Command**: -```bash -uv run --with pandas --with requests \ - fastmcp run server.py \ - --transport http \ - --port 8000 \ - --log-level INFO -``` - -**Equivalent fastmcp.json**: -```json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - "dependencies": ["pandas", "requests"] - }, - "deployment": { - "transport": "http", - "port": 8000, - "log_level": "INFO" - } -} -``` - -Now simply run: -```bash -fastmcp run # Automatically finds and uses fastmcp.json -``` - -The configuration file approach provides better documentation, easier sharing, and consistent execution across different environments while maintaining the flexibility to override settings when needed. \ No newline at end of file diff --git a/docs/v3/development/contributing.mdx b/docs/v3/development/contributing.mdx deleted file mode 100644 index c8772765a..000000000 --- a/docs/v3/development/contributing.mdx +++ /dev/null @@ -1,198 +0,0 @@ ---- -title: "Contributing" -description: "Development workflow for FastMCP contributors" -icon: code-pull-request ---- - -Contributing to FastMCP means joining a community that values clean, maintainable code and thoughtful API design. All contributions are valued - from fixing typos in documentation to implementing major features. - -## Design Principles - -Every contribution should advance these principles: - -- 🚀 **Fast** — High-level interfaces mean less code and faster development -- 🍀 **Simple** — Minimal boilerplate; the obvious way should be the right way -- 🐍 **Pythonic** — Feels natural to Python developers; no surprising patterns -- 🔍 **Complete** — Everything needed for production: auth, testing, deployment, observability - -PRs are evaluated against these principles. Code that makes FastMCP slower, harder to reason about, less Pythonic, or less complete will be rejected. - -## Issues - -### Issue First, Code Second - -**Every pull request requires a corresponding issue - no exceptions.** This requirement creates a collaborative space where approach, scope, and alignment are established before code is written. Issues serve as design documents where maintainers and contributors discuss implementation strategy, identify potential conflicts with existing patterns, and ensure proposed changes advance FastMCP's vision. - -**FastMCP is an opinionated framework, not a kitchen sink.** The maintainers have strong beliefs about what FastMCP should and shouldn't do. Just because something takes N lines of code and you want it in fewer lines doesn't mean FastMCP should take on the maintenance burden or endorse that pattern. This is judged at the maintainers' discretion. - -Use issues to understand scope BEFORE opening PRs. The issue discussion determines whether a feature belongs in core, contrib, or not at all. - -### Writing Good Issues - -FastMCP is an extremely highly-trafficked repository maintained by a very small team. Issues that appear to transfer burden to maintainers without any effort to validate the problem will be closed. Please help the maintainers help you by always providing a minimal reproducible example and clearly describing the problem. - -**LLM-generated issues will be closed immediately.** Issues that contain paragraphs of unnecessary explanation, verbose problem descriptions, or obvious LLM authorship patterns obfuscate the actual problem and transfer burden to maintainers. - -Write clear, concise issues that: -- State the problem directly -- Provide a minimal reproducible example -- Skip unnecessary background or context -- Take responsibility for clear communication - -Issues may be labeled "Invalid" simply due to confusion caused by verbosity or not adhering to the guidelines outlined here. - -## Pull Requests - -PRs that deviate from FastMCP's core principles will be rejected regardless of implementation quality. **PRs are NOT for iterating on ideas** - they should only be opened for ideas that already have a bias toward acceptance based on issue discussion. - - -### Development Environment - -#### Installation - -To contribute to FastMCP, you'll need to set up a development environment with all necessary tools and dependencies. - -```bash -# Clone the repository -git clone https://github.com/PrefectHQ/fastmcp.git -cd fastmcp - -# Install all dependencies including dev tools -uv sync - -# Install prek hooks -uv run prek install -``` - -In addition, some development commands require [just](https://github.com/casey/just) to be installed. - -Prek hooks will run automatically on every commit to catch issues before they reach CI. If you see failures, fix them before committing - never commit broken code expecting to fix it later. - -### Development Standards - -#### Scope - -Large pull requests create review bottlenecks and quality risks. Unless you're fixing a discrete bug or making an incredibly well-scoped change, keep PRs small and focused. - -A PR that changes 50 lines across 3 files can be thoroughly reviewed in minutes. A PR that changes 500 lines across 20 files requires hours of careful analysis and often hides subtle issues. - -Breaking large features into smaller PRs: -- Creates better review experiences -- Makes git history clear -- Simplifies debugging with bisect -- Reduces merge conflicts -- Gets your code merged faster - -#### Code Quality - -FastMCP values clarity over cleverness. Every line you write will be maintained by someone else - possibly years from now, possibly without context about your decisions. - -**PRs can be rejected for two opposing reasons:** -1. **Insufficient quality** - Code that doesn't meet our standards for clarity, maintainability, or idiomaticity -2. **Overengineering** - Code that is overbearing, unnecessarily complex, or tries to be too clever - -The focus is on idiomatic, high-quality Python. FastMCP uses patterns like `NotSet` type as an alternative to `None` in certain situations - follow existing patterns. - -#### Required Practices - -**Full type annotations** on all functions and methods. They catch bugs before runtime and serve as inline documentation. - -**Async/await patterns** for all I/O operations. Even if your specific use case doesn't need concurrency, consistency means users can compose features without worrying about blocking operations. - -**Descriptive names** make code self-documenting. `auth_token` is clear; `tok` requires mental translation. - -**Specific exception types** make error handling predictable. Catching `ValueError` tells readers exactly what error you expect. Never use bare `except` clauses. - -#### Anti-Patterns to Avoid - -**Complex one-liners** are hard to debug and modify. Break operations into clear steps. - -**Mutable default arguments** cause subtle bugs. Use `None` as the default and create the mutable object inside the function. - -**Breaking established patterns** confuses readers. If you must deviate, discuss in the issue first. - -### Prek Checks - -```bash -# Runs automatically on commit, or manually: -uv run prek run --all-files -``` - -This runs three critical tools: -- **Ruff**: Linting and formatting -- **Prettier**: Code formatting -- **ty**: Static type checking - -Pytest runs separately as a distinct workflow step after prek checks pass. CI will reject PRs that fail these checks. Always run them locally first. - -### Testing - -Tests are documentation that shows how features work. Good tests give reviewers confidence and help future maintainers understand intent. - -```bash -# Run specific test directory -uv run pytest tests/server/ -v - -# Run all tests before submitting PR -uv run pytest -``` - -Every new feature needs tests. See the [Testing Guide](/development/tests) for patterns and requirements. - -### Documentation - -A feature doesn't exist unless it's documented. Note that FastMCP's hosted documentation always tracks the main branch - users who want historical documentation can clone the repo, checkout a specific tag, and host it themselves. - -```bash -# Preview documentation locally -just docs -``` - -Documentation requirements: -- **Explain concepts in prose first** - Code without context is just syntax -- **Complete, runnable examples** - Every code block should be copy-pasteable -- **Register in docs.json** - Makes pages appear in navigation -- **Version badges** - Mark when features were added using `<VersionBadge />` - -#### SDK Documentation - -FastMCP's SDK documentation is auto-generated from the source code docstrings and type annotations. It is automatically updated on every merge to main by a GitHub Actions workflow, so users are *not* responsible for keeping the documentation up to date. However, to generate it proactively, you can use the following command: - -```bash -just api-ref-all -``` - -### Submitting Your PR - -#### Before Submitting - -1. **Run all checks**: `uv run prek run --all-files && uv run pytest` -2. **Keep scope small**: One feature or fix per PR -3. **Write clear description**: Your PR description becomes permanent documentation -4. **Update docs**: Include documentation for API changes - -#### PR Description - -Write PR descriptions that explain: -- What problem you're solving -- Why you chose this approach -- Any trade-offs or alternatives considered -- Migration path for breaking changes - -Focus on the "why" - the code shows the "what". Keep it concise but complete. - -#### What We Look For - -**Framework Philosophy**: FastMCP is NOT trying to do all things or provide all shortcuts. Features are rejected when they don't align with the framework's vision, even if perfectly implemented. The burden of proof is on the PR to demonstrate value. - -**Code Quality**: We verify code follows existing patterns. Consistency reduces cognitive load. When every module works similarly, developers understand new code quickly. - -**Test Coverage**: Not every line needs testing, but every behavior does. Tests document intent and protect against regressions. - -**Breaking Changes**: May be acceptable in minor versions but must be clearly documented. See the [versioning policy](/development/releases#versioning-policy). - -## Special Modules - -**`contrib`**: Community-maintained patterns and utilities. Original authors maintain their contributions. Not representative of the core framework. - -**`experimental`**: Maintainer-developed features that may preview future functionality. Can break or be deleted at any time without notice. Pin your FastMCP version when using these features. \ No newline at end of file diff --git a/docs/v3/development/releases.mdx b/docs/v3/development/releases.mdx deleted file mode 100644 index 346462736..000000000 --- a/docs/v3/development/releases.mdx +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: "Releases" -description: "FastMCP versioning and release process" -icon: "truck-fast" ---- - -FastMCP releases frequently to deliver features quickly in the rapidly evolving MCP ecosystem. We use semantic versioning pragmatically - the Model Context Protocol is young, patterns are still emerging, and waiting for perfect stability would mean missing opportunities to empower developers with better tools. - -## Versioning Policy - -### Semantic Versioning - -**Major (x.0.0)**: Complete API redesigns - -Major versions represent fundamental shifts. FastMCP 2.x is entirely different from 1.x in both implementation and design philosophy. - -**Minor (2.x.0)**: New features and evolution - -<Warning> -Unlike traditional semantic versioning, minor versions **may** include [breaking changes](#breaking-changes) when necessary for the ecosystem's evolution. This flexibility is essential in a young ecosystem where perfect backwards compatibility would prevent important improvements. -</Warning> - -FastMCP always targets the most current MCP Protocol version. Breaking changes in the MCP spec or MCP SDK automatically flow through to FastMCP - we prioritize staying current with the latest features and conventions over maintaining compatibility with older protocol versions. - -**Patch (2.0.x)**: Bug fixes and refinements - -Patch versions contain only bug fixes without breaking changes. These are safe updates you can apply with confidence. - -### Breaking Changes - -We permit breaking changes in minor versions because the MCP ecosystem is rapidly evolving. Refusing to break problematic APIs would accumulate design debt that eventually makes the framework unusable. Each breaking change represents a deliberate decision to keep FastMCP aligned with the ecosystem's evolution. - -When breaking changes occur: -- They only happen in minor versions (e.g., 2.3.x to 2.4.0) -- Release notes explain what changed and how to migrate -- We provide deprecation warnings at least 1 minor version in advance when possible -- Changes must substantially benefit users to justify disruption - -The public API is what's covered by our compatibility guarantees - these are the parts of FastMCP you can rely on to remain stable within a minor version. The public API consists of: -- `FastMCP` server class, `Client` class, and FastMCP `Context` -- Core MCP components: `Tool`, `Prompt`, `Resource`, `ResourceTemplate`, and transports -- Their public methods and documented behaviors - -Everything else (utilities, private methods, internal modules) may change without notice. This boundary lets us refactor internals and improve implementation details without breaking your code. For production stability, pin to specific versions. - -<Warning> -The `fastmcp.server.auth` module was introduced in 2.12.0 and is exempted from this policy temporarily, meaning it is *expected* to have breaking changes even on patch versions. This is because auth is a rapidly evolving part of the MCP spec and it would be dangerous to be beholden to old decisions. Please pin your FastMCP version if using authentication in production. - -We expect this exemption to last through at least the 2.12.x and 2.13.x release series. -</Warning> - -### Production Use - -Pin to exact versions: -``` -fastmcp==2.11.0 # Good -fastmcp>=2.11.0 # Bad - will install breaking changes -``` - -## Creating Releases - -Our release process is intentionally simple: - -1. Create GitHub release with tag `vMAJOR.MINOR.PATCH` (e.g., `v2.11.0`) -2. Generate release notes automatically, and curate or add additional editorial information as needed -3. GitHub releases automatically trigger PyPI deployments - -This automation lets maintainers focus on code quality rather than release mechanics. - -### Release Cadence - -We follow a feature-driven release cadence rather than a fixed schedule. Minor versions ship approximately every 3-4 weeks when significant functionality is ready. - -Patch releases ship promptly for: -- Critical bug fixes -- Security updates (immediate release) -- Regression fixes - -This approach means you get improvements as soon as they're ready rather than waiting for arbitrary release dates. diff --git a/docs/v3/development/tests.mdx b/docs/v3/development/tests.mdx deleted file mode 100644 index 4653368be..000000000 --- a/docs/v3/development/tests.mdx +++ /dev/null @@ -1,396 +0,0 @@ ---- -title: "Tests" -description: "Testing patterns and requirements for FastMCP" -icon: vial ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -Good tests are the foundation of reliable software. In FastMCP, we treat tests as first-class documentation that demonstrates how features work while protecting against regressions. Every new capability needs comprehensive tests that demonstrate correctness. - -## FastMCP Tests - -### Running Tests - -```bash -# Run all tests -uv run pytest - -# Run specific test file -uv run pytest tests/server/test_auth.py - -# Run with coverage -uv run pytest --cov=fastmcp - -# Skip integration tests for faster runs -uv run pytest -m "not integration" - -# Skip tests that spawn processes -uv run pytest -m "not integration and not client_process" -``` - -Tests should complete in under 1 second unless marked as integration tests. This speed encourages running them frequently, catching issues early. - -### Test Organization - -Our test organization mirrors the source package structure, creating a predictable mapping between code and tests. When you're working on `fastmcp_slim/fastmcp/server/auth.py`, you'll find its tests in `tests/server/test_auth.py`. In rare cases tests are split further - for example, the OpenAPI tests are so comprehensive they're split across multiple files. - -### Test Markers - -We use pytest markers to categorize tests that require special resources or take longer to run: - -```python -@pytest.mark.integration -async def test_github_api_integration(): - """Test GitHub API integration with real service.""" - token = os.getenv("FASTMCP_GITHUB_TOKEN") - if not token: - pytest.skip("FASTMCP_GITHUB_TOKEN not available") - - # Test against real GitHub API - client = GitHubClient(token) - repos = await client.list_repos("prefecthq") - assert "fastmcp" in [repo.name for repo in repos] - -@pytest.mark.client_process -async def test_stdio_transport(): - """Test STDIO transport with separate process.""" - # This spawns a subprocess - async with Client("python examples/simple_echo.py") as client: - result = await client.call_tool("echo", {"message": "test"}) - assert result.content[0].text == "test" -``` - -## Writing Tests - - -### Test Requirements - -Following these practices creates maintainable, debuggable test suites that serve as both documentation and regression protection. - -#### Single Behavior Per Test - -Each test should verify exactly one behavior. When it fails, you need to know immediately what broke. A test that checks five things gives you five potential failure points to investigate. A test that checks one thing points directly to the problem. - -<CodeGroup> - -```python Good: Atomic Test -async def test_tool_registration(): - """Test that tools are properly registered with the server.""" - mcp = FastMCP("test-server") - - @mcp.tool - def add(a: int, b: int) -> int: - return a + b - - tools = mcp.list_tools() - assert len(tools) == 1 - assert tools[0].name == "add" -``` - -```python Bad: Multi-Behavior Test -async def test_server_functionality(): - """Test multiple server features at once.""" - mcp = FastMCP("test-server") - - # Tool registration - @mcp.tool - def add(a: int, b: int) -> int: - return a + b - - # Resource creation - @mcp.resource("config://app") - def get_config(): - return {"version": "1.0"} - - # Authentication setup - mcp.auth = BearerTokenProvider({"token": "user"}) - - # What exactly are we testing? If this fails, what broke? - assert mcp.list_tools() - assert mcp.list_resources() - assert mcp.auth is not None -``` - -</CodeGroup> - -#### Self-Contained Setup - -Every test must create its own setup. Tests should be runnable in any order, in parallel, or in isolation. When a test fails, you should be able to run just that test to reproduce the issue. - -<CodeGroup> - -```python Good: Self-Contained -async def test_tool_execution_with_error(): - """Test that tool errors are properly handled.""" - mcp = FastMCP("test-server") - - @mcp.tool - def divide(a: int, b: int) -> float: - if b == 0: - raise ValueError("Cannot divide by zero") - return a / b - - async with Client(mcp) as client: - with pytest.raises(Exception): - await client.call_tool("divide", {"a": 10, "b": 0}) -``` - -```python Bad: Test Dependencies -# Global state that tests depend on -test_server = None - -def test_setup_server(): - """Setup for other tests.""" - global test_server - test_server = FastMCP("shared-server") - -def test_server_works(): - """Test server functionality.""" - # Depends on test_setup_server running first - assert test_server is not None -``` - -</CodeGroup> - -#### Clear Intent - -Test names and assertions should make the verified behavior obvious. A developer reading your test should understand what feature it validates and how that feature should behave. - -```python -async def test_authenticated_tool_requires_valid_token(): - """Test that authenticated users can access protected tools.""" - mcp = FastMCP("test-server") - mcp.auth = BearerTokenProvider({"secret-token": "test-user"}) - - @mcp.tool - def protected_action() -> str: - return "success" - - async with Client(mcp, auth=BearerAuth("secret-token")) as client: - result = await client.call_tool("protected_action", {}) - assert result.content[0].text == "success" -``` - -#### Using Fixtures - -Use fixtures to create reusable data, server configurations, or other resources for your tests. Note that you should **not** open FastMCP clients in your fixtures as it can create hard-to-diagnose issues with event loops. - -```python -import pytest -from fastmcp import FastMCP, Client - -@pytest.fixture -def weather_server(): - server = FastMCP("WeatherServer") - - @server.tool - def get_temperature(city: str) -> dict: - temps = {"NYC": 72, "LA": 85, "Chicago": 68} - return {"city": city, "temp": temps.get(city, 70)} - - return server - -async def test_temperature_tool(weather_server): - async with Client(weather_server) as client: - result = await client.call_tool("get_temperature", {"city": "LA"}) - assert result.data == {"city": "LA", "temp": 85} -``` - -#### Effective Assertions - -Assertions should be specific and provide context on failure. When a test fails during CI, the assertion message should tell you exactly what went wrong. - -```python -# Basic assertion - minimal context on failure -assert result.status == "success" - -# Better - explains what was expected -assert result.status == "success", f"Expected successful operation, got {result.status}: {result.error}" -``` - -Try not to have too many assertions in a single test unless you truly need to check various aspects of the same behavior. In general, assertions of different behaviors should be in separate tests. - -#### Inline Snapshots - -FastMCP uses `inline-snapshot` for testing complex data structures. On first run of `pytest --inline-snapshot=create` with an empty `snapshot()`, pytest will auto-populate the expected value. To update snapshots after intentional changes, run `pytest --inline-snapshot=fix`. This is particularly useful for testing JSON schemas and API responses. - -```python -from inline_snapshot import snapshot - -async def test_tool_schema_generation(): - """Test that tool schemas are generated correctly.""" - mcp = FastMCP("test-server") - - @mcp.tool - def calculate_tax(amount: float, rate: float = 0.1) -> dict: - """Calculate tax on an amount.""" - return {"amount": amount, "tax": amount * rate, "total": amount * (1 + rate)} - - tools = mcp.list_tools() - schema = tools[0].inputSchema - - # First run: snapshot() is empty, gets auto-populated - # Subsequent runs: compares against stored snapshot - assert schema == snapshot({ - "type": "object", - "properties": { - "amount": {"type": "number"}, - "rate": {"type": "number", "default": 0.1} - }, - "required": ["amount"] - }) -``` - -### In-Memory Testing - -FastMCP uses in-memory transport for testing, where servers and clients communicate directly. The majority of functionality can be tested in a deterministic fashion this way. We use more complex setups only when testing transports themselves. - -The in-memory transport runs the real MCP protocol implementation without network overhead. Instead of deploying your server or managing network connections, you pass your server instance directly to the client. Everything runs in the same Python process - you can set breakpoints anywhere and step through with your debugger. - -```python -from fastmcp import FastMCP, Client - -# Create your server -server = FastMCP("WeatherServer") - -@server.tool -def get_temperature(city: str) -> dict: - """Get current temperature for a city""" - temps = {"NYC": 72, "LA": 85, "Chicago": 68} - return {"city": city, "temp": temps.get(city, 70)} - -async def test_weather_operations(): - # Pass server directly - no deployment needed - async with Client(server) as client: - result = await client.call_tool("get_temperature", {"city": "NYC"}) - assert result.data == {"city": "NYC", "temp": 72} -``` - -This pattern makes tests deterministic and fast - typically completing in milliseconds rather than seconds. - -### Mocking External Dependencies - -FastMCP servers are standard Python objects, so you can mock external dependencies using your preferred approach: - -```python -from unittest.mock import AsyncMock - -async def test_database_tool(): - server = FastMCP("DataServer") - - # Mock the database - mock_db = AsyncMock() - mock_db.fetch_users.return_value = [ - {"id": 1, "name": "Alice"}, - {"id": 2, "name": "Bob"} - ] - - @server.tool - async def list_users() -> list: - return await mock_db.fetch_users() - - async with Client(server) as client: - result = await client.call_tool("list_users", {}) - assert len(result.data) == 2 - assert result.data[0]["name"] == "Alice" - mock_db.fetch_users.assert_called_once() -``` - -### Testing Network Transports - -While in-memory testing covers most unit testing needs, you'll occasionally need to test actual network transports like HTTP or SSE. FastMCP provides two approaches: in-process async servers (preferred), and separate subprocess servers (for special cases). - -#### In-Process Network Testing (Preferred) - -<VersionBadge version="2.13.0" /> - -For most network transport tests, use `run_server_async` as an async context manager. This runs the server as a task in the same process, providing fast, deterministic tests with full debugger support: - -```python -import pytest -from fastmcp import FastMCP, Client -from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.utilities.tests import run_server_async - -def create_test_server() -> FastMCP: - """Create a test server instance.""" - server = FastMCP("TestServer") - - @server.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - return server - -@pytest.fixture -async def http_server() -> str: - """Start server in-process for testing.""" - server = create_test_server() - async with run_server_async(server) as url: - yield url - -async def test_http_transport(http_server: str): - """Test actual HTTP transport behavior.""" - async with Client( - transport=StreamableHttpTransport(http_server) - ) as client: - result = await client.ping() - assert result is True - - greeting = await client.call_tool("greet", {"name": "World"}) - assert greeting.data == "Hello, World!" -``` - -The `run_server_async` context manager automatically handles server lifecycle and cleanup. This approach is faster than subprocess-based testing and provides better error messages. - -#### Subprocess Testing (Special Cases) - -For tests that require complete process isolation (like STDIO transport or testing subprocess behavior), use `run_server_in_process`: - -```python -import pytest -from fastmcp.utilities.tests import run_server_in_process -from fastmcp import FastMCP, Client -from fastmcp.client.transports import StreamableHttpTransport - -def run_server(host: str, port: int) -> None: - """Function to run in subprocess.""" - server = FastMCP("TestServer") - - @server.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - server.run(host=host, port=port) - -@pytest.fixture -async def http_server(): - """Fixture that runs server in subprocess.""" - with run_server_in_process(run_server, transport="http") as url: - yield f"{url}/mcp" - -async def test_http_transport(http_server: str): - """Test actual HTTP transport behavior.""" - async with Client( - transport=StreamableHttpTransport(http_server) - ) as client: - result = await client.ping() - assert result is True -``` - -The `run_server_in_process` utility handles server lifecycle, port allocation, and cleanup automatically. Use this only when subprocess isolation is truly necessary, as it's slower and harder to debug than in-process testing. FastMCP uses the `client_process` marker to isolate these tests in CI. - -### Documentation Testing - -Documentation requires the same validation as code. The `just docs` command launches a local Mintlify server that renders your documentation exactly as users will see it: - -```bash -# Start local documentation server with hot reload -just docs - -# Or run Mintlify directly -mintlify dev -``` - -The local server watches for changes and automatically refreshes. This preview catches formatting issues and helps you see documentation as users will experience it. diff --git a/docs/v3/getting-started/installation.mdx b/docs/v3/getting-started/installation.mdx deleted file mode 100644 index 4dae8e9b7..000000000 --- a/docs/v3/getting-started/installation.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Installation -description: Install FastMCP and verify your setup -icon: arrow-down-to-line ---- -## Install FastMCP - -We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install and manage FastMCP. - -```bash -pip install fastmcp -``` - -Or with uv: - -```bash -uv add fastmcp -``` - -### Optional Dependencies - -FastMCP provides optional extras for specific features. For example, to install the background tasks extra: - -```bash -pip install "fastmcp[tasks]" -``` - -See [Background Tasks](/servers/tasks) for details on the task system. - -### Verify Installation - -To verify that FastMCP is installed correctly, you can run the following command: - -```bash -fastmcp version -``` - -You should see output like the following: - -```bash -$ fastmcp version - -FastMCP version: 3.0.0 -MCP version: 1.25.0 -Python version: 3.12.2 -Platform: macOS-15.3.1-arm64-arm-64bit -FastMCP root path: ~/Developer/fastmcp -``` - -### Dependency Licensing - -<Info> -FastMCP depends on Cyclopts for CLI functionality. Cyclopts v4 includes docutils as a transitive dependency, which has complex licensing that may trigger compliance reviews in some organizations. - -If this is a concern, you can install Cyclopts v5 alpha which removes this dependency: - -```bash -pip install "cyclopts>=5.0.0a1" -``` - -Alternatively, wait for the stable v5 release. See [this issue](https://github.com/BrianPugh/cyclopts/issues/672) for details. -</Info> -## Upgrading - -### From FastMCP 2.0 - -See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complete list of breaking changes and migration steps. - -### From the MCP SDK - -#### From FastMCP 1.0 - -If you're using FastMCP 1.0 via the `mcp` package (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers, it's a single import change. See the [full upgrade guide](/getting-started/upgrading/from-mcp-sdk) for details. - -#### From the Low-Level Server API - -If you built your server directly on the `mcp` package's `Server` class — with `list_tools()`/`call_tool()` handlers and hand-written JSON Schema — see the [migration guide](/getting-started/upgrading/from-low-level-sdk) for a full walkthrough. - -## Troubleshooting - -### `import fastmcp` fails after a pip upgrade - -This affects one specific case: upgrading to FastMCP 3.3 or later from FastMCP 3.2 or earlier with `pip`. Fresh installs and `uv` upgrades are unaffected, so you can skip this unless you did exactly that. - -If `import fastmcp` raises `ModuleNotFoundError`, or `from fastmcp import FastMCP` raises `ImportError`, immediately after the upgrade, your install is in a half-removed state. Reinstall in a single step: - -```bash -pip install --force-reinstall fastmcp -``` - -If that doesn't resolve it, remove both distributions and reinstall from a clean state: - -```bash -pip uninstall -y fastmcp fastmcp-slim -pip install fastmcp -``` - -FastMCP 3.3 moved the importable code from the `fastmcp` distribution into `fastmcp-slim`. During a single-command `pip` upgrade, pip can install the new files and then delete them while uninstalling the old `fastmcp` distribution, whose file manifest still lists those paths. `uv` uninstalls before it installs, so it is unaffected. - -## Versioning Policy - -FastMCP follows semantic versioning with pragmatic adaptations for the rapidly evolving MCP ecosystem. Breaking changes may occur in minor versions (e.g., 2.3.x to 2.4.0) when necessary to stay current with the MCP Protocol. - -For production use, always pin to exact versions: -``` -fastmcp==3.0.0 # Good -fastmcp>=3.0.0 # Bad - may install breaking changes -``` - -See the full [versioning and release policy](/development/releases#versioning-policy) for details on our public API, deprecation practices, and breaking change philosophy. - -## Contributing to FastMCP - -Interested in contributing to FastMCP? See the [Contributing Guide](/development/contributing) for details on: -- Setting up your development environment -- Running tests and pre-commit hooks -- Submitting issues and pull requests -- Code standards and review process diff --git a/docs/v3/getting-started/quickstart.mdx b/docs/v3/getting-started/quickstart.mdx deleted file mode 100644 index 97d9f3c79..000000000 --- a/docs/v3/getting-started/quickstart.mdx +++ /dev/null @@ -1,164 +0,0 @@ ---- -title: Quickstart -icon: rocket-launch ---- - -Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, give it a visual UI, and deploy it to Prefect Horizon. - -If you haven't already installed FastMCP, follow the [installation instructions](/getting-started/installation). - -## Create a FastMCP Server - -A FastMCP server is a collection of tools, resources, and other MCP components. To create a server, start by instantiating the `FastMCP` class. - -Create a new file called `my_server.py` and add the following code: - -```python my_server.py -from fastmcp import FastMCP - -mcp = FastMCP("My MCP Server") -``` - - -That's it! You've created a FastMCP server, albeit a very boring one. Let's add a tool to make it more interesting. - - -## Add a Tool - -To add a tool that returns a simple greeting, write a function and decorate it with `@mcp.tool` to register it with the server: - -```python my_server.py {5-7} -from fastmcp import FastMCP - -mcp = FastMCP("My MCP Server") - -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" -``` - - -## Run the Server - -The simplest way to run your FastMCP server is to call its `run()` method. You can choose between different transports, like `stdio` for local servers, or `http` for remote access: - -<CodeGroup> - -```python my_server.py (stdio) {9, 10} -from fastmcp import FastMCP - -mcp = FastMCP("My MCP Server") - -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -if __name__ == "__main__": - mcp.run() -``` - -```python my_server.py (HTTP) {9, 10} -from fastmcp import FastMCP - -mcp = FastMCP("My MCP Server") - -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -</CodeGroup> - -This lets us run the server with `python my_server.py`. The stdio transport is the traditional way to connect MCP servers to clients, while the HTTP transport enables remote connections. - -<Tip> -Why do we need the `if __name__ == "__main__":` block? - -The `__main__` block is recommended for consistency and compatibility, ensuring your server works with all MCP clients that execute your server file as a script. Users who will exclusively run their server with the FastMCP CLI can omit it, as the CLI imports the server object directly. -</Tip> - -### Using the FastMCP CLI - -You can also use the `fastmcp run` command to start your server. Note that the FastMCP CLI **does not** execute the `__main__` block of your server file. Instead, it imports your server object and runs it with whatever transport and options you provide. - -For example, to run this server with the default stdio transport (no matter how you called `mcp.run()`), you can use the following command: -```bash -fastmcp run my_server.py:mcp -``` - -To run this server with the HTTP transport, you can use the following command: -```bash -fastmcp run my_server.py:mcp --transport http --port 8000 -``` - -## Call Your Server - -Once your server is running with HTTP transport, you can connect to it with a FastMCP client or any LLM client that supports the MCP protocol: - -```python my_client.py -import asyncio -from fastmcp import Client - -client = Client("http://localhost:8000/mcp") - -async def call_tool(name: str): - async with client: - result = await client.call_tool("greet", {"name": name}) - print(result) - -asyncio.run(call_tool("Ford")) -``` - -Note that: -- FastMCP clients are asynchronous, so we need to use `asyncio.run` to run the client -- We must enter a client context (`async with client:`) before using the client -- You can make multiple client calls within the same context - -## Give Your Tool a UI - -Tools normally return text, but any tool can return an interactive UI instead. Add `app=True` to your tool decorator and return a [Prefab](https://prefab.prefect.io) component — the host renders it as a chart, table, form, or any other visual element right in the conversation. This requires the `apps` extra (`pip install "fastmcp[apps]"`). - -The `app=True` flag tells FastMCP to wire up the renderer and protocol metadata automatically. The tool still works like any other MCP tool — it receives arguments and returns a result — but the result is a component tree that the host displays visually instead of as plain text. - -```python my_server.py -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, Heading, Text, Badge, Row -from fastmcp import FastMCP - -mcp = FastMCP("My MCP Server") - - -@mcp.tool(app=True) -def greet(name: str) -> PrefabApp: - """Greet someone with a visual card.""" - with Column(gap=4, css_class="p-6") as view: - Heading(f"Hello, {name}!") - with Row(gap=2, align="center"): - Text("Status") - Badge("Greeted", variant="success") - - return PrefabApp(view=view) -``` - -You can preview app tools locally with `fastmcp dev apps my_server.py` — no MCP host required. See the [Apps overview](/apps/overview) for the full guide, including state management, forms, charts, and server-connected interactivity. - -## Deploy to Prefect Horizon - -[Prefect Horizon](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides managed hosting, authentication, access control, and observability for MCP servers. - -<Info> -Horizon is **free for personal projects** and offers enterprise governance for teams. -</Info> - -To deploy your server, you'll need a [GitHub account](https://github.com). Once you have one, you can deploy your server in three steps: - -1. Push your `my_server.py` file to a GitHub repository -2. Sign in to [Prefect Horizon](https://horizon.prefect.io?utm_source=gofastmcp&utm_medium=docs) with your GitHub account -3. Create a new project from your repository and enter `my_server.py:mcp` as the server entrypoint - -That's it! Horizon will build and deploy your server, making it available at a URL like `https://your-project.fastmcp.app/mcp`. You can chat with it to test its functionality, or connect to it from any LLM client that supports the MCP protocol. - -For more details, see the [Prefect Horizon guide](/deployment/prefect-horizon). diff --git a/docs/v3/getting-started/upgrading/from-fastmcp-2.mdx b/docs/v3/getting-started/upgrading/from-fastmcp-2.mdx deleted file mode 100644 index 1e659e76a..000000000 --- a/docs/v3/getting-started/upgrading/from-fastmcp-2.mdx +++ /dev/null @@ -1,444 +0,0 @@ ---- -title: Upgrading from FastMCP 2 -sidebarTitle: "From FastMCP 2" -description: Migration instructions for upgrading between FastMCP versions -icon: up ---- - -This guide covers breaking changes and migration steps when upgrading FastMCP. - -## v3.0.0 - -For most servers, upgrading to v3 is straightforward. The breaking changes below affect deprecated constructor kwargs, sync-to-async shifts, a few renamed methods, and some less commonly used features. - -### Install - -Since you already have `fastmcp` installed, you need to explicitly request the new version — `pip install fastmcp` won't upgrade an existing installation: - -```bash -pip install --upgrade fastmcp -# or -uv add --upgrade fastmcp -``` - -If you pin versions in a requirements file or `pyproject.toml`, update your pin to `fastmcp>=3.0.0,<4`. - -<Info> -**New repository home.** As part of the v3 release, FastMCP's GitHub repository has moved from `jlowin/fastmcp` to [`PrefectHQ/fastmcp`](https://github.com/PrefectHQ/fastmcp) under [Prefect](https://prefect.io)'s stewardship. GitHub automatically redirects existing clones and bookmarks, so nothing breaks — but you can update your local remote whenever convenient: - -```bash -git remote set-url origin https://github.com/PrefectHQ/fastmcp.git -``` - -If you reference the repository URL in dependency specifications (e.g., `git+https://github.com/jlowin/fastmcp.git`), update those to the new location. -</Info> - -<Prompt description="Copy this prompt into any LLM along with your server code to get automated upgrade guidance."> -You are upgrading a FastMCP v2 server to FastMCP v3.0. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2 and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. - -BREAKING CHANGES (will crash at import or runtime): - -1. CONSTRUCTOR KWARGS REMOVED: FastMCP() no longer accepts these kwargs (raises TypeError): - - Transport settings: host, port, log_level, debug, sse_path, streamable_http_path, json_response, stateless_http - Fix: pass to run() or run_http_async() instead, e.g. mcp.run(transport="http", host="0.0.0.0", port=8080) - - message_path: set via environment variable FASTMCP_MESSAGE_PATH only (not a run() kwarg) - - Duplicate handling: on_duplicate_tools, on_duplicate_resources, on_duplicate_prompts - Fix: use unified on_duplicate= parameter - - Tool settings: tool_serializer, include_tags, exclude_tags, tool_transformations - Fix: use ToolResult returns, server.enable()/disable(), server.add_transform() - -2. COMPONENT METHODS REMOVED: - - tool.enable()/disable() raises NotImplementedError - Fix: server.disable(names={"tool_name"}, components={"tool"}) or server.disable(tags={"tag"}) - - get_tools()/get_resources()/get_prompts()/get_resource_templates() removed - Fix: use list_tools()/list_resources()/list_prompts()/list_resource_templates() — these return lists, not dicts - -3. ASYNC STATE: ctx.set_state() and ctx.get_state() are now async (must be awaited). - State values must be JSON-serializable unless serializable=False is passed. - Each FastMCP instance has its own state store, so serializable state set by parent middleware isn't visible to mounted tools by default. - Fix: pass the same session_state_store to both servers, or use serializable=False (request-scoped state is always shared). - -4. PROMPTS: mcp.types.PromptMessage replaced by fastmcp.prompts.Message. - Before: PromptMessage(role="user", content=TextContent(type="text", text="Hello")) - After: Message("Hello") # role defaults to "user", accepts plain strings - Also: if prompts return raw dicts like `{"role": "user", "content": "..."}`, these must become Message objects. - v2 silently coerced dicts; v3 requires typed Message objects or plain strings. - -5. AUTH PROVIDERS: No longer auto-load from env vars. Pass client_id, client_secret explicitly via os.environ. - -6. WSTRANSPORT: Removed. Use StreamableHttpTransport. - -7. OPENAPI: timeout parameter removed from OpenAPIProvider. Set timeout on the httpx.AsyncClient instead. - -8. METADATA: Namespace changed from "_fastmcp" to "fastmcp" in tool.meta. The include_fastmcp_meta parameter is removed (always included). - -9. ENV VAR: FASTMCP_SHOW_CLI_BANNER renamed to FASTMCP_SHOW_SERVER_BANNER. - -10. DECORATORS: @mcp.tool, @mcp.resource, @mcp.prompt now return the original function, not a component object. Code that accesses .name, .description, or other component attributes on the decorated result will crash with AttributeError. - Fix: set FASTMCP_DECORATOR_MODE=object for v2 compat (itself deprecated). - -11. OAUTH STORAGE: Default OAuth client storage changed from DiskStore to FileTreeStore due to pickle deserialization vulnerability in diskcache (CVE-2025-69872). Clients using default storage will re-register automatically on first connection. If using DiskStore explicitly, switch to FileTreeStore (with key/collection sanitization strategies) or add pip install 'py-key-value-aio[disk]'. - -12. REPO MOVE: GitHub repository moved from jlowin/fastmcp to PrefectHQ/fastmcp. Update git remotes and dependency URLs that reference the old location. - -13. BACKGROUND TASKS: FastMCP's background task system (SEP-1686) is now an optional dependency. If the code uses task=True or TaskConfig, add pip install "fastmcp[tasks]". - -DEPRECATIONS (still work but emit warnings): - -- mount(prefix="x") -> mount(namespace="x") -- import_server(sub) -> mount(sub) -- FastMCP.as_proxy(url) -> from fastmcp.server import create_proxy; create_proxy(url) -- from fastmcp.server.proxy -> from fastmcp.server.providers.proxy -- from fastmcp.server.openapi import FastMCPOpenAPI -> from fastmcp.server.providers.openapi import OpenAPIProvider; use FastMCP("name", providers=[OpenAPIProvider(...)]) -- mcp.add_tool_transformation(name, cfg) -> from fastmcp.server.transforms import ToolTransform; mcp.add_transform(ToolTransform(...)) - -For each issue found, show the original line, explain why it breaks, and provide the corrected code. -</Prompt> - -### Breaking Changes - -**Transport and server settings removed from constructor** - -In v2, you could configure transport settings directly in the `FastMCP()` constructor. In v3, `FastMCP()` is purely about your server's identity and behavior — transport configuration happens when you actually start serving. Passing any of the old kwargs now raises `TypeError` with a migration hint. - -```python -# Before -mcp = FastMCP("server", host="0.0.0.0", port=8080) -mcp.run() - -# After -mcp = FastMCP("server") -mcp.run(transport="http", host="0.0.0.0", port=8080) -``` - -The full list of removed kwargs and their replacements: - -- `host`, `port`, `log_level`, `debug`, `sse_path`, `streamable_http_path`, `json_response`, `stateless_http` — pass to `run()`, `run_http_async()`, or `http_app()`, or set via environment variables (e.g. `FASTMCP_HOST`) -- `message_path` — set via environment variable `FASTMCP_MESSAGE_PATH` only (not a `run()` kwarg) -- `on_duplicate_tools`, `on_duplicate_resources`, `on_duplicate_prompts` — consolidated into a single `on_duplicate=` parameter -- `tool_serializer` — return [`ToolResult`](/servers/tools#custom-serialization) from your tools instead -- `include_tags` / `exclude_tags` — use `server.enable(tags=..., only=True)` / `server.disable(tags=...)` after construction -- `tool_transformations` — use `server.add_transform(ToolTransform(...))` after construction - -**OAuth storage backend changed (diskcache CVE)** - -The default OAuth client storage has moved from `DiskStore` to `FileTreeStore` to address a pickle deserialization vulnerability in diskcache ([CVE-2025-69872](https://github.com/PrefectHQ/fastmcp/issues/3166)). - -If you were using the default storage (i.e., not passing an explicit `client_storage`), clients will need to re-register on their first connection after upgrading. This happens automatically — no user action required, and it's the same flow that already occurs whenever a server restarts with in-memory storage. - -If you were passing a `DiskStore` explicitly, you can either [switch to `FileTreeStore`](/servers/storage-backends) (recommended) or keep using `DiskStore` by adding the dependency yourself. - -<Warning> -When switching to `FileTreeStore`, you **must** configure key and collection sanitization strategies. Without them, keys containing special characters (such as URL-based OAuth client IDs) will cause filesystem errors. See the [File Storage](/servers/storage-backends#file-storage) section for the recommended setup. -</Warning> - -<Warning> -Keeping `DiskStore` requires `pip install 'py-key-value-aio[disk]'`, which re-introduces the vulnerable `diskcache` package into your dependency tree. -</Warning> - -**Component enable()/disable() moved to server** - -In v2, you could enable or disable individual components by calling methods on the component object itself. In v3, visibility is controlled through the server (or provider), which lets you target components by name, tag, or type without needing a reference to the object: - -```python -# Before -tool = await server.get_tool("my_tool") -tool.disable() - -# After -server.disable(names={"my_tool"}, components={"tool"}) -``` - -Calling `.enable()` or `.disable()` on a component object now raises `NotImplementedError`. See [Visibility](/servers/visibility) for the full API, including tag-based filtering and per-session visibility. - -**Listing methods renamed and return lists** - -The `get_tools()`, `get_resources()`, `get_prompts()`, and `get_resource_templates()` methods have been renamed to `list_tools()`, `list_resources()`, `list_prompts()`, and `list_resource_templates()`. More importantly, they now return lists instead of dicts — so code that indexes by name needs to change: - -```python -# Before -tools = await server.get_tools() -tool = tools["my_tool"] - -# After -tools = await server.list_tools() -tool = next((t for t in tools if t.name == "my_tool"), None) -``` - -**Prompts use Message class** - -Prompt functions now use FastMCP's `Message` class instead of `mcp.types.PromptMessage`. The new class is simpler — it accepts a plain string and defaults to `role="user"`, so most prompts become one-liners: - -```python -# Before -from mcp.types import PromptMessage, TextContent - -@mcp.prompt -def my_prompt() -> PromptMessage: - return PromptMessage(role="user", content=TextContent(type="text", text="Hello")) - -# After -from fastmcp.prompts import Message - -@mcp.prompt -def my_prompt() -> Message: - return Message("Hello") -``` - -If your prompt functions return raw dicts with `role` and `content` keys, those also need to change. v2 silently coerced dicts into prompt messages, but v3 requires typed `Message` objects (or plain strings for single user messages): - -```python -# Before (v2 accepted this) -@mcp.prompt -def my_prompt(): - return [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "How can I help?"}, - ] - -# After -from fastmcp.prompts import Message - -@mcp.prompt -def my_prompt() -> list[Message]: - return [ - Message("Hello"), - Message("How can I help?", role="assistant"), - ] -``` - -**Context state methods are async** - -`ctx.set_state()` and `ctx.get_state()` are now async because state in v3 is session-scoped and backed by a pluggable storage backend (rather than a simple dict). This means state persists across multiple tool calls within the same session: - -```python -# Before -ctx.set_state("key", "value") -value = ctx.get_state("key") - -# After -await ctx.set_state("key", "value") -value = await ctx.get_state("key") -``` - -State values must also be JSON-serializable by default (dicts, lists, strings, numbers, etc.). If you need to store non-serializable values like an HTTP client, pass `serializable=False` — these values are request-scoped and only available during the current tool call: - -```python -await ctx.set_state("client", my_http_client, serializable=False) -``` - -**Mounted servers have isolated state stores** - -Each `FastMCP` instance has its own state store. In v2 this wasn't noticeable because mounted tools ran in the parent's context, but in v3's provider architecture each server is isolated. Non-serializable state (`serializable=False`) is request-scoped and automatically shared across mount boundaries. For serializable state, pass the same `session_state_store` to both servers: - -```python -from fastmcp import FastMCP -from key_value.aio.stores.memory import MemoryStore - -store = MemoryStore() -parent = FastMCP("Parent", session_state_store=store) -child = FastMCP("Child", session_state_store=store) -parent.mount(child, namespace="child") -``` - -**Auth provider environment variables removed** - -In v2, auth providers like `GitHubProvider` could auto-load configuration from environment variables with a `FASTMCP_SERVER_AUTH_*` prefix. This magic has been removed — pass values explicitly: - -```python -# Before (v2) — client_id and client_secret loaded automatically -# from FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID, etc. -auth = GitHubProvider() - -# After (v3) — pass values explicitly -import os -from fastmcp.server.auth.providers.github import GitHubProvider - -auth = GitHubProvider( - client_id=os.environ["GITHUB_CLIENT_ID"], - client_secret=os.environ["GITHUB_CLIENT_SECRET"], -) -``` - -**WSTransport removed** - -The deprecated WebSocket client transport has been removed. Use `StreamableHttpTransport` instead: - -```python test="skip" -# Before -from fastmcp.client.transports import WSTransport -transport = WSTransport("ws://localhost:8000/ws") - -# After -from fastmcp.client.transports import StreamableHttpTransport -transport = StreamableHttpTransport("http://localhost:8000/mcp") -``` - -**OpenAPI `timeout` parameter removed** - -`OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout: - -```python -# Before -provider = OpenAPIProvider(spec, client, timeout=60) - -# After -client = httpx.AsyncClient(base_url="https://api.example.com", timeout=60) -provider = OpenAPIProvider(spec, client) -``` - -**Metadata namespace renamed** - -The FastMCP metadata key in component `meta` dicts changed from `_fastmcp` to `fastmcp`. If you read metadata from tool or resource objects, update the key: - -```python -# Before -tags = tool.meta.get("_fastmcp", {}).get("tags", []) - -# After -tags = tool.meta.get("fastmcp", {}).get("tags", []) -``` - -Metadata is now always included — the `include_fastmcp_meta` parameter has been removed from `FastMCP()` and `to_mcp_tool()`, so there is no way to suppress it. - -**Server banner environment variable renamed** - -`FASTMCP_SHOW_CLI_BANNER` is now `FASTMCP_SHOW_SERVER_BANNER`. - -**Decorators return functions** - -In v2, `@mcp.tool` transformed your function into a `FunctionTool` object. In v3, decorators return your original function unchanged — which means decorated functions stay callable for testing, reuse, and composition: - -```python -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -greet("World") # Works! Returns "Hello, World!" -``` - -If you have code that treats the decorated result as a `FunctionTool` (e.g., accessing `.name` or `.description`), set `FASTMCP_DECORATOR_MODE=object` for v2 compatibility. This escape hatch is itself deprecated and will be removed in a future release. - -**Background tasks require optional dependency** - -FastMCP's background task system (SEP-1686) is now behind an optional extra. If your server uses background tasks, install with: - -```bash -pip install "fastmcp[tasks]" -``` - -Without the extra, configuring a tool with `task=True` or `TaskConfig` will raise an import error at runtime. See [Background Tasks](/servers/tasks) for details. - -### Deprecated Features - -These still work but emit warnings. Update when convenient. - -**mount() prefix → namespace** - -```python -# Deprecated -main.mount(subserver, prefix="api") - -# New -main.mount(subserver, namespace="api") -``` - -**import_server() → mount()** - -```python -# Deprecated -main.import_server(subserver) - -# New -main.mount(subserver) -``` - -**Module import paths for proxy and OpenAPI** - -The proxy and OpenAPI modules have moved under `providers` to reflect v3's provider-based architecture: - -```python test="skip" -# Deprecated -from fastmcp.server.proxy import FastMCPProxy -from fastmcp.server.openapi import FastMCPOpenAPI - -# New -from fastmcp.server.providers.proxy import FastMCPProxy -from fastmcp.server.providers.openapi import OpenAPIProvider -``` - -`FastMCPOpenAPI` itself is deprecated — use `FastMCP` with an `OpenAPIProvider` instead: - -```python test="skip" -# Deprecated -from fastmcp.server.openapi import FastMCPOpenAPI -server = FastMCPOpenAPI(spec, client) - -# New -from fastmcp import FastMCP -from fastmcp.server.providers.openapi import OpenAPIProvider -server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)]) -``` - -**add_tool_transformation() → add_transform()** - -```python -# Deprecated -mcp.add_tool_transformation("name", config) - -# New -from fastmcp.server.transforms import ToolTransform -mcp.add_transform(ToolTransform({"name": config})) -``` - -**FastMCP.as_proxy() → create_proxy()** - -```python -# Deprecated -proxy = FastMCP.as_proxy("http://example.com/mcp") - -# New -from fastmcp.server import create_proxy -proxy = create_proxy("http://example.com/mcp") -``` - -## v2.14.0 - -### OpenAPI Parser Promotion - -The experimental OpenAPI parser is now standard. Update imports: - -```python test="skip" -# Before -from fastmcp.experimental.server.openapi import FastMCPOpenAPI - -# After -from fastmcp.server.openapi import FastMCPOpenAPI -``` - -### Removed Deprecated Features - -- `BearerAuthProvider` → use `JWTVerifier` -- `Context.get_http_request()` → use `get_http_request()` from dependencies -- `from fastmcp import Image` → use `from fastmcp.utilities.types import Image` -- `FastMCP(dependencies=[...])` → use `fastmcp.json` configuration -- `FastMCPProxy(client=...)` → use `client_factory=lambda: ...` -- `output_schema=False` → use `output_schema=None` - -## v2.13.0 - -### OAuth Token Key Management - -The OAuth proxy now issues its own JWT tokens. For production, provide explicit keys: - -```python -auth = GitHubProvider( - client_id=os.environ["GITHUB_CLIENT_ID"], - client_secret=os.environ["GITHUB_CLIENT_SECRET"], - base_url="https://your-server.com", - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=RedisStore(host="redis.example.com"), -) -``` - -See [OAuth Token Security](/deployment/http#oauth-token-security) for details. diff --git a/docs/v3/getting-started/welcome.mdx b/docs/v3/getting-started/welcome.mdx deleted file mode 100644 index d42dc39b7..000000000 --- a/docs/v3/getting-started/welcome.mdx +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: "Welcome to FastMCP" -sidebarTitle: "Welcome!" -description: The fast, Pythonic way to build MCP servers, clients, and applications. -icon: hand-wave -mode: center ---- -{/* <img - src="/assets/brand/f-watercolor-waves-4.png" - - alt="'F' logo on a watercolor background" - noZoom - className="rounded-2xl block dark:hidden" - /> - <img - src="/assets/brand/f-watercolor-waves-4-dark.png" - alt="'F' logo on a watercolor background" - noZoom - className="rounded-2xl hidden dark:block" - /> - - - */} -<video - autoPlay - muted - loop - playsInline - className="rounded-2xl block dark:hidden" - src="/assets/brand/f-watercolor-waves-4-animated.mp4" -></video> -<video - autoPlay - muted - loop - playsInline - className="rounded-2xl hidden dark:block" - src="/assets/brand/f-watercolor-waves-4-dark-animated.mp4" -></video> - - -**FastMCP is the standard framework for building MCP applications.** The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production — build servers that expose capabilities, connect clients to any MCP service, and give your tools interactive UIs: - -```python {1} -from fastmcp import FastMCP - -mcp = FastMCP("Demo 🚀") - -@mcp.tool -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - -if __name__ == "__main__": - mcp.run() -``` - - -## Move Fast and Make Things - -The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) lets you give agents access to your tools and data. But building an effective MCP application is harder than it looks. - -FastMCP handles all of it. Declare a tool with a Python function, and the schema, validation, and documentation are generated automatically. Connect to a server with a URL, and transport negotiation, authentication, and protocol lifecycle are managed for you. You focus on your logic, and the MCP part just works: **with FastMCP, best practices are built in.** - -**That's why FastMCP is the standard framework for working with MCP.** FastMCP 1.0 was incorporated into the official MCP Python SDK in 2024. Today, the actively maintained standalone project is downloaded a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages. - -FastMCP has three pillars: - -<CardGroup cols={3}> - <Card title="Servers" img="/assets/images/servers-card.png" href="/servers/server"> - Expose tools, resources, and prompts to LLMs. - </Card> - <Card title="Apps" img="/assets/images/apps-card.png" href="/apps/overview"> - Give your tools interactive UIs rendered directly in the conversation. - </Card> - <Card title="Clients" img="/assets/images/clients-card.png" href="/clients/client"> - Connect to any MCP server — local or remote, programmatic or CLI. - </Card> -</CardGroup> - -**[Servers](/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](/clients/client)** connect to any server with full protocol support. And **[Apps](/apps/overview)** give your tools interactive UIs rendered directly in the conversation. - -Ready to build? Start with the [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart). - -FastMCP is made with 💙 by [Prefect](https://www.prefect.io/). - -## Run FastMCP in production with Horizon - -FastMCP is the standard way to build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_body)** is the enterprise MCP gateway for running them safely. - -Built by the FastMCP team, Horizon packages the best practices we've learned shipping the world's most popular MCP framework. - -Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents. - -Start with FastMCP. [Scale with Horizon →](https://www.prefect.io/horizon?utm_source=gofastmcp&utm_medium=docs&utm_campaign=docs_welcome&utm_content=welcome_cta) - -<Tip> -**This documentation reflects FastMCP's `main` branch**, meaning it always reflects the latest development version. Features are generally marked with version badges (e.g. `New in version: 3.0.0`) to indicate when they were introduced. Note that this may include features that are not yet released. -</Tip> - -## LLM-Friendly Docs - -The FastMCP documentation is available in multiple LLM-friendly formats: - -### MCP Server - -The FastMCP docs are accessible via MCP! The server URL is `https://gofastmcp.com/mcp`. - -In fact, you can use FastMCP to search the FastMCP docs: - -```python -import asyncio -from fastmcp import Client - -async def main(): - async with Client("https://gofastmcp.com/mcp") as client: - result = await client.call_tool( - name="search_fast_mcp", - arguments={"query": "deploy a FastMCP server"} - ) - print(result) - -asyncio.run(main()) -``` - -### Text Formats - -The docs are also available in [llms.txt format](https://llmstxt.org/): -- [llms.txt](https://gofastmcp.com/llms.txt) - A sitemap listing all documentation pages -- [llms-full.txt](https://gofastmcp.com/llms-full.txt) - The entire documentation in one file (may exceed context windows) - -Any page can be accessed as markdown by appending `.md` to the URL. For example, this page becomes `https://gofastmcp.com/getting-started/welcome.md`. - -You can also copy any page as markdown by pressing "Cmd+C" (or "Ctrl+C" on Windows) on your keyboard. diff --git a/docs/v3/integrations/anthropic.mdx b/docs/v3/integrations/anthropic.mdx deleted file mode 100644 index 08b9b2c9c..000000000 --- a/docs/v3/integrations/anthropic.mdx +++ /dev/null @@ -1,228 +0,0 @@ ---- -title: Anthropic API 🤝 FastMCP -sidebarTitle: Anthropic API -description: Connect FastMCP servers to the Anthropic API -icon: message-code ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - - -Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) supports MCP servers as remote tool sources. This tutorial will show you how to create a FastMCP server and deploy it to a public URL, then how to call it from the Messages API. - -<Tip> -Currently, the MCP connector only accesses **tools** from MCP servers—it queries the `list_tools` endpoint and exposes those functions to Claude. Other MCP features like resources and prompts are not currently supported. You can read more about the MCP connector in the [Anthropic documentation](https://docs.anthropic.com/en/docs/agents-and-tools/mcp-connector). -</Tip> - -## Create a Server - -First, create a FastMCP server with the tools you want to expose. For this example, we'll create a server with a single tool that rolls dice. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -## Deploy the Server - -Your server must be deployed to a public URL in order for Anthropic to access it. The MCP connector supports both SSE and Streamable HTTP transports. - -For development, you can use tools like `ngrok` to temporarily expose a locally-running server to the internet. We'll do that for this example (you may need to install `ngrok` and create a free account), but you can use any other method to deploy your server. - -Assuming you saved the above code as `server.py`, you can run the following two commands in two separate terminals to deploy your server and expose it to the internet: - -<CodeGroup> -```bash FastMCP server -python server.py -``` - -```bash ngrok -ngrok http 8000 -``` -</CodeGroup> - -<Warning> -This exposes your unauthenticated server to the internet. Only run this command in a safe environment if you understand the risks. -</Warning> - -## Call the Server - -To use the Messages API with MCP servers, you'll need to install the Anthropic Python SDK (not included with FastMCP): - -```bash -pip install anthropic -``` - -You'll also need to authenticate with Anthropic. You can do this by setting the `ANTHROPIC_API_KEY` environment variable. Consult the Anthropic SDK documentation for more information. - -```bash -export ANTHROPIC_API_KEY="your-api-key" -``` - -Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. **At this time you must also include the `extra_headers` parameter with the `anthropic-beta` header.** - -```python {5, 13-22} -import anthropic -from rich import print - -# Your server URL (replace with your actual URL) -url = 'https://your-server-url.com' - -client = anthropic.Anthropic() - -response = client.beta.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=1000, - messages=[{"role": "user", "content": "Roll a few dice!"}], - mcp_servers=[ - { - "type": "url", - "url": f"{url}/mcp/", - "name": "dice-server", - } - ], - extra_headers={ - "anthropic-beta": "mcp-client-2025-04-04" - } -) - -print(response.content) -``` - -If you run this code, you'll see something like the following output: - -```text -I'll roll some dice for you! Let me use the dice rolling tool. - -I rolled 3 dice and got: 4, 2, 6 - -The results were 4, 2, and 6. Would you like me to roll again or roll a different number of dice? -``` - - -## Authentication - -<VersionBadge version="2.6.0" /> - -The MCP connector supports OAuth authentication through authorization tokens, which means you can secure your server while still allowing Anthropic to access it. - -### Server Authentication - -The simplest way to add authentication to the server is to use a bearer token scheme. - -For this example, we'll quickly generate our own tokens with FastMCP's `RSAKeyPair` utility, but this may not be appropriate for production use. For more details, see the complete server-side [Token Verification](/servers/auth/token-verification) documentation. - -We'll start by creating an RSA key pair to sign and verify tokens. - -```python -from fastmcp.server.auth.providers.jwt import RSAKeyPair - -key_pair = RSAKeyPair.generate() -access_token = key_pair.create_token(audience="dice-server") -``` - -<Warning> -FastMCP's `RSAKeyPair` utility is for development and testing only. -</Warning> - -Next, we'll create a `JWTVerifier` to authenticate the server. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import JWTVerifier - -auth = JWTVerifier( - public_key=key_pair.public_key, - audience="dice-server", -) - -mcp = FastMCP(name="Dice Roller", auth=auth) -``` - -Here is a complete example that you can copy/paste. For simplicity and the purposes of this example only, it will print the token to the console. **Do NOT do this in production!** - -```python server.py [expandable] -from fastmcp import FastMCP -from fastmcp.server.auth import JWTVerifier -from fastmcp.server.auth.providers.jwt import RSAKeyPair -import random - -key_pair = RSAKeyPair.generate() -access_token = key_pair.create_token(audience="dice-server") - -auth = JWTVerifier( - public_key=key_pair.public_key, - audience="dice-server", -) - -mcp = FastMCP(name="Dice Roller", auth=auth) - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - print(f"\n---\n\n🔑 Dice Roller access token:\n\n{access_token}\n\n---\n") - mcp.run(transport="http", port=8000) -``` - -### Client Authentication - -If you try to call the authenticated server with the same Anthropic code we wrote earlier, you'll get an error indicating that the server rejected the request because it's not authenticated. - -```text -Error code: 400 - { - "type": "error", - "error": { - "type": "invalid_request_error", - "message": "MCP server 'dice-server' requires authentication. Please provide an authorization_token.", - }, -} -``` - -To authenticate the client, you can pass the token using the `authorization_token` parameter in your MCP server configuration: - -```python {8, 21} -import anthropic -from rich import print - -# Your server URL (replace with your actual URL) -url = 'https://your-server-url.com' - -# Your access token (replace with your actual token) -access_token = 'your-access-token' - -client = anthropic.Anthropic() - -response = client.beta.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=1000, - messages=[{"role": "user", "content": "Roll a few dice!"}], - mcp_servers=[ - { - "type": "url", - "url": f"{url}/mcp/", - "name": "dice-server", - "authorization_token": access_token - } - ], - extra_headers={ - "anthropic-beta": "mcp-client-2025-04-04" - } -) - -print(response.content) -``` - -You should now see the dice roll results in the output. diff --git a/docs/v3/integrations/auth0.mdx b/docs/v3/integrations/auth0.mdx deleted file mode 100644 index 65f9d3873..000000000 --- a/docs/v3/integrations/auth0.mdx +++ /dev/null @@ -1,195 +0,0 @@ ---- -title: Auth0 OAuth 🤝 FastMCP -sidebarTitle: Auth0 -description: Secure your FastMCP server with Auth0 OAuth -icon: shield-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.12.4" /> - -This guide shows you how to secure your FastMCP server using **Auth0 OAuth**. While Auth0 does have support for Dynamic Client Registration, it is not enabled by default so this integration uses the [**OIDC Proxy**](/servers/auth/oidc-proxy) pattern to bridge Auth0's dynamic OIDC configuration with MCP's authentication requirements. - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. An **[Auth0 Account](https://auth0.com/)** with access to create Applications -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Create an Auth0 Application - -Create an Application in your Auth0 settings to get the credentials needed for authentication: - -<Steps> -<Step title="Navigate to Applications"> - Go to **Applications → Applications** in your Auth0 account. - - Click **"+ Create Application"** to create a new application. -</Step> - -<Step title="Create Your Application"> - - **Name**: Choose a name users will recognize (e.g., "My FastMCP Server") - - **Choose an application type**: Choose "Single Page Web Applications" - - Click **Create** to create the application -</Step> - -<Step title="Configure Your Application"> - Select the "Settings" tab for your application, then find the "Application URIs" section. - - - **Allowed Callback URLs**: Your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) - - Click **Save** to save your changes - - <Warning> - The callback URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. - </Warning> - - <Tip> - If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0Provider. - </Tip> -</Step> - -<Step title="Save Your Credentials"> - After creating the app, in the "Basic Information" section you'll see: - - - **Client ID**: A public identifier like `tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB` - - **Client Secret**: A private hidden value that should always be stored securely - - <Tip> - Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. - </Tip> -</Step> - -<Step title="Select Your Audience"> - Go to **Applications → APIs** in your Auth0 account. - - - Find the API that you want to use for your application - - **API Audience**: A URL that uniquely identifies the API - - <Tip> - Store this along with of the credentials above. Never commit this to version control. Use environment variables or a secrets manager in production. - </Tip> -</Step> -</Steps> - -### Step 2: FastMCP Configuration - -Create your FastMCP server using the `Auth0Provider`. - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider - -# The Auth0Provider utilizes Auth0 OIDC configuration -auth_provider = Auth0Provider( - config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL - client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID - client_secret="vPYqbjemq...", # Your Auth0 application Client Secret - audience="https://...", # Your Auth0 API audience - base_url="http://localhost:8000", # Must match your application configuration - # redirect_path="/auth/callback" # Default value, customize if needed -) - -mcp = FastMCP(name="Auth0 Secured App", auth=auth_provider) - -# Add a protected tool to test authentication -@mcp.tool -async def get_token_info() -> dict: - """Returns information about the Auth0 token.""" - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - - return { - "issuer": token.claims.get("iss"), - "audience": token.claims.get("aud"), - "scope": token.claims.get("scope") - } -``` - -## Testing - -### Running the Server - -Start your FastMCP server with HTTP transport to enable OAuth flows: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Your server is now running and protected by Auth0 authentication. - -### Testing with a Client - -Create a test client that authenticates with your Auth0-protected server: - -```python test_client.py -from fastmcp import Client -import asyncio - -async def main(): - # The client will automatically handle Auth0 OAuth flows - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - # First-time connection will open Auth0 login in your browser - print("✓ Authenticated with Auth0!") - - # Test the protected tool - result = await client.call_tool("get_token_info") - print(f"Auth0 audience: {result['audience']}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to Auth0's authorization page -2. After you authorize the app, you'll be redirected back -3. The client receives the token and can make authenticated requests - -## Production Configuration - -<VersionBadge version="2.13.0" /> - -For production deployments with persistent token management across server restarts, configure `jwt_signing_key`, and `client_storage`: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -# Production setup with encrypted persistent token storage -auth_provider = Auth0Provider( - config_url="https://.../.well-known/openid-configuration", - client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", - client_secret="vPYqbjemq...", - audience="https://...", - base_url="https://your-production-domain.com", - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production Auth0 App", auth=auth_provider) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. -</Info> diff --git a/docs/v3/integrations/authkit.mdx b/docs/v3/integrations/authkit.mdx deleted file mode 100644 index c77175201..000000000 --- a/docs/v3/integrations/authkit.mdx +++ /dev/null @@ -1,106 +0,0 @@ ---- -title: AuthKit 🤝 FastMCP -sidebarTitle: AuthKit -description: Secure your FastMCP server with AuthKit by WorkOS -icon: shield-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.11.0" /> - -This guide shows you how to secure your FastMCP server using WorkOS's **AuthKit**, a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern with [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators: AuthKit issues tokens whose `aud` claim is bound to your server's resource URL, and FastMCP validates that claim automatically. - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. A **[WorkOS Account](https://workos.com/)** and a new **Project**. -2. An **[AuthKit](https://www.authkit.com/)** instance configured within your WorkOS project. -3. Your FastMCP server's URL (can be localhost for development, e.g., `http://127.0.0.1:8000`). - -### Step 1: WorkOS Dashboard - -In the WorkOS Dashboard, go to **Connect → Configuration** and configure: - -<Steps> -<Step title="MCP Auth"> - Enable **Dynamic Client Registration** (DCR) so MCP clients can register themselves. Alternatively, enable **Client ID Metadata Document** (CIMD) if your clients support it. -</Step> - -<Step title="MCP resource indicators"> - Add your FastMCP server's resource URL (e.g., `http://127.0.0.1:8000/mcp`) as a valid resource indicator. - - This must exactly match what FastMCP advertises in its protected resource metadata. Start your server first and it will log the correct URL on startup — copy that value. - - Without this step, AuthKit falls back to a default environment-scoped audience and audience validation will fail with a 401. -</Step> - -<Step title="Note Your AuthKit Domain"> - Find your **AuthKit Domain** on the configuration page. It will look like `https://your-project-12345.authkit.app`. You'll need this for your FastMCP server configuration. -</Step> -</Steps> - -### Step 2: FastMCP Configuration - -Create your FastMCP server file and use the `AuthKitProvider` to handle all the OAuth integration automatically: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider - -# AuthKitProvider automatically discovers WorkOS endpoints, configures JWT -# validation, and binds the token audience to this server's resource URL. -auth_provider = AuthKitProvider( - authkit_domain="https://your-project-12345.authkit.app", - base_url="http://127.0.0.1:8000", # Use your actual server URL -) - -mcp = FastMCP(name="AuthKit Secured App", auth=auth_provider) -``` - -When the server starts, it logs the resource URL it is validating against. Paste that URL into your Dashboard's **MCP resource indicators** list. - -## Testing - -To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the `authkit_domain` and `base_url` with your actual values!), you can run the following command: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -AuthKit defaults DCR clients to `client_secret_basic` for token exchange, which conflicts with how some MCP clients send credentials. To avoid token exchange errors, register as a public client by setting `token_endpoint_auth_method` to `"none"`: - -```python client.py -from fastmcp import Client -from fastmcp.client.auth import OAuth -import asyncio - -auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"}) - -async def main(): - async with Client("http://127.0.0.1:8000/mcp", auth=auth) as client: - assert await client.ping() - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## Production Configuration - -For production deployments, load sensitive configuration from environment variables: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider - -# Load configuration from environment variables -auth = AuthKitProvider( - authkit_domain=os.environ.get("AUTHKIT_DOMAIN"), - base_url=os.environ.get("BASE_URL", "https://your-server.com"), -) - -mcp = FastMCP(name="AuthKit Secured App", auth=auth) -``` diff --git a/docs/v3/integrations/aws-cognito.mdx b/docs/v3/integrations/aws-cognito.mdx deleted file mode 100644 index b7df29222..000000000 --- a/docs/v3/integrations/aws-cognito.mdx +++ /dev/null @@ -1,278 +0,0 @@ ---- -title: AWS Cognito OAuth 🤝 FastMCP -sidebarTitle: AWS Cognito -description: Secure your FastMCP server with AWS Cognito user pools -icon: aws ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.12.4" /> - -This guide shows you how to secure your FastMCP server using **AWS Cognito user pools**. Since AWS Cognito doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge AWS Cognito's traditional OAuth with MCP's authentication requirements. It also includes robust JWT token validation, ensuring enterprise-grade authentication. - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. An **[AWS Account](https://aws.amazon.com/)** with access to create AWS Cognito user pools -2. Basic familiarity with AWS Cognito concepts (user pools, app clients) -3. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Create an AWS Cognito User Pool and App Client - -Set up AWS Cognito user pool with an app client to get the credentials needed for authentication: - -<Steps> -<Step title="Navigate to AWS Cognito"> - Go to the **[AWS Cognito Console](https://console.aws.amazon.com/cognito/)** and ensure you're in your desired AWS region. - - Select **"User pools"** from the side navigation (click on the hamburger icon at the top left in case you don't see any), and click **"Create user pool"** to create a new user pool. -</Step> - -<Step title="Define Your Application"> - AWS Cognito now provides a streamlined setup experience: - - 1. **Application type**: Select **"Traditional web application"** (this is the correct choice for FastMCP server-side authentication) - 2. **Name your application**: Enter a descriptive name (e.g., `FastMCP Server`) - - The traditional web application type automatically configures: - - Server-side authentication with client secrets - - Authorization code grant flow - - Appropriate security settings for confidential clients - - <Info> - Choose "Traditional web application" rather than SPA, Mobile app, or Machine-to-machine options. This ensures proper OAuth 2.0 configuration for FastMCP. - </Info> -</Step> - -<Step title="Configure Options"> - AWS will guide you through configuration options: - - - **Sign-in identifiers**: Choose how users will sign in (email, username, or phone) - - **Required attributes**: Select any additional user information you need - - **Return URL**: Add your callback URL (e.g., `http://localhost:8000/auth/callback` for development) - - <Tip> - The simplified interface handles most OAuth security settings automatically based on your application type selection. - </Tip> -</Step> - -<Step title="Review and Create"> - Review your configuration and click **"Create user pool"**. - - After creation, you'll see your user pool details. Save these important values: - - **User pool ID** (format: `eu-central-1_XXXXXXXXX`) - - **Client ID** (found under → "Applications" → "App clients" in the side navigation → \<Your application name, e.g., `FastMCP Server`\> → "App client information") - - **Client Secret** (found under → "Applications" → "App clients" in the side navigation → \<Your application name, e.g., `FastMCP Server`\> → "App client information") - - <Tip> - The user pool ID and app client credentials are all you need for FastMCP configuration. - </Tip> -</Step> - -<Step title="Configure OAuth Settings"> - Under "Login pages" in your app client's settings, you can double check and adjust the OAuth configuration: - - - **Allowed callback URLs**: Add your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) - - **Allowed sign-out URLs**: Optional, for logout functionality - - **OAuth 2.0 grant types**: Ensure "Authorization code grant" is selected - - **OpenID Connect scopes**: Select scopes your application needs (e.g., `openid`, `email`, `profile`) - - <Tip> - For local development, you can use `http://localhost` URLs. For production, you must use HTTPS. - </Tip> -</Step> - -<Step title="Configure Resource Server"> - AWS Cognito requires a resource server entry to support OAuth with protected resources. Without this, token exchange will fail with an `invalid_grant` error. - - Navigate to **"Branding" → "Domain"** in the side navigation, then: - - 1. Click **"Create resource server"** - 2. **Resource server name**: Enter a descriptive name (e.g., `My MCP Server`) - 3. **Resource server identifier**: Enter your MCP endpoint URL exactly as it will be accessed (e.g., `http://localhost:8000/mcp` for development, or `https://your-server.com/mcp` for production) - 4. Click **"Create resource server"** - - <Warning> - The resource server identifier must exactly match your `base_url + mcp_path`. For the default configuration with `base_url="http://localhost:8000"` and `path="/mcp"`, use `http://localhost:8000/mcp`. - </Warning> -</Step> - -<Step title="Save Your Credentials"> - After setup, you'll have: - - - **User Pool ID**: Format like `eu-central-1_XXXXXXXXX` - - **Client ID**: Your application's client identifier - - **Client Secret**: Generated client secret (keep secure) - - **AWS Region**: Where Your AWS Cognito user pool is located - - <Tip> - Store these credentials securely. Never commit them to version control. Use environment variables or AWS Secrets Manager in production. - </Tip> -</Step> -</Steps> - -### Step 2: FastMCP Configuration - -Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cognito's JWT tokens and user claims automatically: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.aws import AWSCognitoProvider -from fastmcp.server.dependencies import get_access_token - -# The AWSCognitoProvider handles JWT validation and user claims -auth_provider = AWSCognitoProvider( - user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID - aws_region="eu-central-1", # AWS region (defaults to eu-central-1) - client_id="your-app-client-id", # Your app client ID - client_secret="your-app-client-secret", # Your app client Secret - base_url="http://localhost:8000", # Must match your callback URL - # redirect_path="/auth/callback" # Default value, customize if needed -) - -mcp = FastMCP(name="AWS Cognito Secured App", auth=auth_provider) - -# Add a protected tool to test authentication -@mcp.tool -async def get_access_token_claims() -> dict: - """Get the authenticated user's access token claims.""" - token = get_access_token() - return { - "sub": token.claims.get("sub"), - "username": token.claims.get("username"), - "cognito:groups": token.claims.get("cognito:groups", []), - } -``` - -## Testing - -### Running the Server - -Start your FastMCP server with HTTP transport to enable OAuth flows: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Your server is now running and protected by AWS Cognito OAuth authentication. - -### Testing with a Client - -Create a test client that authenticates with Your AWS Cognito-protected server: - -```python test_client.py -from fastmcp import Client -import asyncio - -async def main(): - # The client will automatically handle AWS Cognito OAuth - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - # First-time connection will open AWS Cognito login in your browser - print("✓ Authenticated with AWS Cognito!") - - # Test the protected tool - print("Calling protected tool: get_access_token_claims") - result = await client.call_tool("get_access_token_claims") - user_data = result.data - print("Available access token claims:") - print(f"- sub: {user_data.get('sub', 'N/A')}") - print(f"- username: {user_data.get('username', 'N/A')}") - print(f"- cognito:groups: {user_data.get('cognito:groups', [])}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to AWS Cognito's hosted UI login page -2. After you sign in (or sign up), you'll be redirected back to your MCP server -3. The client receives the JWT token and can make authenticated requests - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. -</Info> - -## Production Configuration - -<VersionBadge version="2.13.0" /> - -For production deployments with persistent token management across server restarts, configure `jwt_signing_key`, and `client_storage`: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.aws import AWSCognitoProvider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -# Production setup with encrypted persistent token storage -auth_provider = AWSCognitoProvider( - user_pool_id="eu-central-1_XXXXXXXXX", - aws_region="eu-central-1", - client_id="your-app-client-id", - client_secret="your-app-client-secret", - base_url="https://your-production-domain.com", - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production AWS Cognito App", auth=auth_provider) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> - -## Features - -### JWT Token Validation - -The AWS Cognito provider includes robust JWT token validation: - -- **Signature Verification**: Validates tokens against AWS Cognito's public keys (JWKS) -- **Expiration Checking**: Automatically rejects expired tokens -- **Issuer Validation**: Ensures tokens come from your specific AWS Cognito user pool -- **Scope Enforcement**: Verifies required OAuth scopes are present - -### User Claims and Groups - -Access rich user information from AWS Cognito JWT tokens: - -```python -from fastmcp.server.dependencies import get_access_token - -@mcp.tool -async def admin_only_tool() -> str: - """A tool only available to admin users.""" - token = get_access_token() - user_groups = token.claims.get("cognito:groups", []) - - if "admin" not in user_groups: - raise ValueError("This tool requires admin access") - - return "Admin access granted!" -``` - -### Enterprise Integration - -Perfect for enterprise environments with: - -- **Single Sign-On (SSO)**: Integrate with corporate identity providers -- **Multi-Factor Authentication (MFA)**: Leverage AWS Cognito's built-in MFA -- **User Groups**: Role-based access control through AWS Cognito groups -- **Custom Attributes**: Access custom user attributes defined in your AWS Cognito user pool -- **Compliance**: Meet enterprise security and compliance requirements \ No newline at end of file diff --git a/docs/v3/integrations/azure.mdx b/docs/v3/integrations/azure.mdx deleted file mode 100644 index cba92349e..000000000 --- a/docs/v3/integrations/azure.mdx +++ /dev/null @@ -1,542 +0,0 @@ ---- -title: Azure (Microsoft Entra ID) OAuth 🤝 FastMCP -sidebarTitle: Azure (Entra ID) -description: Secure your FastMCP server with Azure/Microsoft Entra OAuth -icon: microsoft ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.13.0" /> - -This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. FastMCP validates Azure JWTs against your application's client_id. - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. An **[Azure Account](https://portal.azure.com/)** with access to create App registrations -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) -3. Your Azure tenant ID (found in Azure Portal under Microsoft Entra ID) - -### Step 1: Create an Azure App Registration - -Create an App registration in Azure Portal to get the credentials needed for authentication: - -<Steps> -<Step title="Navigate to App registrations"> - Go to the [Azure Portal](https://portal.azure.com) and navigate to **Microsoft Entra ID → App registrations**. - - Click **"New registration"** to create a new application. -</Step> - -<Step title="Configure Your Application"> - Fill in the application details: - - - **Name**: Choose a name users will recognize (e.g., "My FastMCP Server") - - **Supported account types**: Choose based on your needs: - - **Single tenant**: Only users in your organization - - **Multitenant**: Users in any Microsoft Entra directory - - **Multitenant + personal accounts**: Any Microsoft account - - **Redirect URI**: Select "Web" and enter your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) - - <Warning> - The redirect URI must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. For local development, Azure allows `http://localhost` URLs. For production, you must use HTTPS. - </Warning> - - <Tip> - If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider. - </Tip> - - - **Expose an API**: Configure your Application ID URI and define scopes - - Go to **Expose an API** in the App registration sidebar. - - Click **Set** next to "Application ID URI" and choose one of: - - Keep the default `api://{client_id}` - - Set a custom value, following the supported formats (see [Identifier URI restrictions](https://learn.microsoft.com/en-us/entra/identity-platform/identifier-uri-restrictions)) - - Click **Add a scope** and create a scope your app will require, for example: - - Scope name: `read` (or `write`, etc.) - - Admin consent display name/description: as appropriate for your org - - Who can consent: as needed (Admins only or Admins and users) - - - **Configure Access Token Version**: Ensure your app uses access token v2 - - Go to **Manifest** in the App registration sidebar. - - Find the `requestedAccessTokenVersion` property and set it to `2`: - ```json - "api": { - "requestedAccessTokenVersion": 2 - } - ``` - - Click **Save** at the top of the manifest editor. - - <Warning> - Access token v2 is required for FastMCP's Azure integration to work correctly. If this is not set, you may encounter authentication errors. - </Warning> - - <Note> - In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`. - </Note> - - -</Step> - - -<Step title="Create Client Secret"> - After registration, navigate to **Certificates & secrets** in your app's settings. - - - Click **"New client secret"** - - Add a description (e.g., "FastMCP Server") - - Choose an expiration period - - Click **"Add"** - - <Warning> - Copy the secret value immediately - it won't be shown again! You'll need to create a new secret if you lose it. - </Warning> -</Step> - -<Step title="Note Your Credentials"> - From the **Overview** page of your app registration, note: - - - **Application (client) ID**: A UUID like `835f09b6-0f0f-40cc-85cb-f32c5829a149` - - **Directory (tenant) ID**: A UUID like `08541b6e-646d-43de-a0eb-834e6713d6d5` - - **Client Secret**: The value you copied in the previous step - - <Tip> - Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. - </Tip> -</Step> -</Steps> - -### Step 2: FastMCP Configuration - -Create your FastMCP server using the `AzureProvider`, which handles Azure's OAuth flow automatically: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider - -# The AzureProvider handles Azure's token format and validation -auth_provider = AzureProvider( - client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID - client_secret="your-client-secret", # Your Azure App Client Secret - tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED) - base_url="http://localhost:8000", # Must match your App registration - required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App - # identifier_uri defaults to api://{client_id} - # identifier_uri="api://your-api-id", - # Optional: request additional upstream scopes in the authorize request - # additional_authorize_scopes=["User.Read", "openid", "email"], - # redirect_path="/auth/callback" # Default value, customize if needed - # base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com) -) - -mcp = FastMCP(name="Azure Secured App", auth=auth_provider) - -# Add a protected tool to test authentication -@mcp.tool -async def get_user_info() -> dict: - """Returns information about the authenticated Azure user.""" - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - # The AzureProvider stores user data in token claims - return { - "azure_id": token.claims.get("sub"), - "email": token.claims.get("email"), - "name": token.claims.get("name"), - "job_title": token.claims.get("job_title"), - "office_location": token.claims.get("office_location") - } -``` - -<Note> -**Important**: The `tenant_id` parameter is **REQUIRED**. Azure no longer supports using "common" for new applications due to security requirements. You must use one of: - -- **Your specific tenant ID**: Found in Azure Portal (e.g., `08541b6e-646d-43de-a0eb-834e6713d6d5`) -- **"organizations"**: For work and school accounts only -- **"consumers"**: For personal Microsoft accounts only - -Using your specific tenant ID is recommended for better security and control. -</Note> - -<Note> -**Important**: The `required_scopes` parameter is **REQUIRED** and must include at least one scope. Azure's OAuth API requires the `scope` parameter in all authorization requests - you cannot authenticate without specifying at least one scope. Use the unprefixed scope names from your Azure App registration (e.g., `["read", "write"]`). These scopes must be created under **Expose an API** in your App registration. -</Note> - -### Scope Handling - -FastMCP automatically prefixes `required_scopes` with your `identifier_uri` (e.g., `api://your-client-id`) since these are your custom API scopes. Scopes in `additional_authorize_scopes` are sent as-is since they target external resources like Microsoft Graph. - -**`required_scopes`** — Your custom API scopes, defined in Azure "Expose an API": - -| You write | Sent to Azure | Validated on tokens | -|-----------|---------------|---------------------| -| `mcp-read` | `api://xxx/mcp-read` | ✓ | -| `my.scope` | `api://xxx/my.scope` | ✓ | -| `openid` | `openid` | ✗ (OIDC scope) | -| `api://xxx/read` | `api://xxx/read` | ✓ | - -**`additional_authorize_scopes`** — External scopes (e.g., Microsoft Graph) for server-side use: - -| You write | Sent to Azure | Validated on tokens | -|-----------|---------------|---------------------| -| `User.Read` | `User.Read` | ✗ | -| `Mail.Send` | `Mail.Send` | ✗ | - -<Note> -`offline_access` is automatically included to obtain refresh tokens. FastMCP manages token refreshing automatically. -</Note> - -<Info> -**Why aren't `additional_authorize_scopes` validated?** Azure issues separate tokens per resource. The access token FastMCP receives is for *your API*—Graph scopes aren't in its `scp` claim. To call Graph APIs, your server uses the upstream Azure token in an on-behalf-of (OBO) flow. -</Info> - -<Note> -OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are never prefixed and excluded from validation because Azure doesn't include them in access token `scp` claims. -</Note> - -## Testing - -### Running the Server - -Start your FastMCP server with HTTP transport to enable OAuth flows: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Your server is now running and protected by Azure OAuth authentication. - -### Testing with a Client - -Create a test client that authenticates with your Azure-protected server: - -```python test_client.py -from fastmcp import Client -import asyncio - -async def main(): - # The client will automatically handle Azure OAuth - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - # First-time connection will open Azure login in your browser - print("✓ Authenticated with Azure!") - - # Test the protected tool - result = await client.call_tool("get_user_info") - print(f"Azure user: {result['email']}") - print(f"Name: {result['name']}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to Microsoft's authorization page -2. Sign in with your Microsoft account (work, school, or personal based on your tenant configuration) -3. Grant the requested permissions -4. After authorization, you'll be redirected back -5. The client receives the token and can make authenticated requests - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. -</Info> - -## Production Configuration - -<VersionBadge version="2.13.0" /> - -For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -# Production setup with encrypted persistent token storage -auth_provider = AzureProvider( - client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", - client_secret="your-client-secret", - tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", - base_url="https://your-production-domain.com", - required_scopes=["your-scope"], - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production Azure App", auth=auth_provider) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> - -## Token Verification Only (Managed Identity) - -<VersionBadge version="2.15.0" /> - -For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full `AzureProvider`. - -This pattern is ideal when: -- Your infrastructure handles authentication (e.g., Managed Identity) -- You don't need the OAuth proxy flow (no `client_secret` required) -- You just need to verify that incoming Azure AD tokens are valid - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth import RemoteAuthProvider -from fastmcp.server.auth.providers.azure import AzureJWTVerifier -from pydantic import AnyHttpUrl - -tenant_id = "your-tenant-id" -client_id = "your-client-id" - -# AzureJWTVerifier auto-configures JWKS, issuer, and audience -verifier = AzureJWTVerifier( - client_id=client_id, - tenant_id=tenant_id, - required_scopes=["access_as_user"], # Scope names from Azure Portal -) - -auth = RemoteAuthProvider( - token_verifier=verifier, - authorization_servers=[ - AnyHttpUrl(f"https://login.microsoftonline.com/{tenant_id}/v2.0") - ], - base_url="https://your-container-app.azurecontainerapps.io", -) - -mcp = FastMCP(name="Azure MI App", auth=auth) -``` - -`AzureJWTVerifier` handles Azure's scope format automatically. You write scope names exactly as they appear in Azure Portal under **Expose an API** (e.g., `access_as_user`). The verifier validates tokens using the short-form scopes that Azure puts in the `scp` claim, while advertising the full URI scopes (e.g., `api://your-client-id/access_as_user`) in OAuth metadata so MCP clients know what to request. - -<Note> -For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`. -</Note> - -## On-Behalf-Of (OBO) - -<VersionBadge version="3.0.0" /> - -The On-Behalf-Of (OBO) flow allows your FastMCP server to call downstream Microsoft APIs—like Microsoft Graph—using the authenticated user's identity. When a user authenticates to your MCP server, you receive a token for your API. OBO exchanges that token for a new token that can call other services, maintaining the user's identity and permissions throughout the chain. - -This pattern is useful when your tools need to access user-specific data from Microsoft services: reading emails, accessing calendar events, querying SharePoint, or any other Graph API operation that requires user context. - -<Note> -OBO features require the `azure` extra: - -```bash -pip install 'fastmcp[azure]' -``` -</Note> - -### Azure Portal Setup - -OBO requires additional configuration in your Azure App registration beyond basic authentication. - -<Steps> -<Step title="Add API Permissions"> - In your App registration, navigate to **API permissions** and add the Microsoft Graph permissions your tools will need. - - - Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** - - Select the permissions required for your use case (e.g., `Mail.Read`, `Calendars.Read`, `User.Read`) - - Repeat for any other APIs you need to call - - <Warning> - Only add delegated permissions for OBO. Application permissions bypass user context entirely and are inappropriate for the OBO flow. - </Warning> -</Step> - -<Step title="Grant Admin Consent"> - OBO requires admin consent for the permissions you've added. In the **API permissions** page, click **Grant admin consent for [Your Organization]**. - - Without admin consent, OBO token exchanges will fail with an `AADSTS65001` error indicating the user or administrator hasn't consented to use the application. - - <Tip> - For development, you can grant consent for just your own account. For production, an Azure AD administrator must grant tenant-wide consent. - </Tip> -</Step> -</Steps> - -### Configure AzureProvider for OBO - -The `additional_authorize_scopes` parameter tells Azure which downstream API permissions to include during the initial authorization. These scopes establish what your server can request through OBO later. - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider - -auth_provider = AzureProvider( - client_id="your-client-id", - client_secret="your-client-secret", - tenant_id="your-tenant-id", - base_url="http://localhost:8000", - required_scopes=["mcp-access"], # Your API scope - # Include Graph scopes for OBO - additional_authorize_scopes=[ - "https://graph.microsoft.com/Mail.Read", - "https://graph.microsoft.com/User.Read", - "offline_access", # Enables refresh tokens - ], -) - -mcp = FastMCP(name="Graph-Enabled Server", auth=auth_provider) -``` - -Scopes listed in `additional_authorize_scopes` are requested during the initial OAuth flow but aren't validated on incoming tokens. They establish permission for your server to later exchange the user's token for downstream API access. - -<Info> -Use fully-qualified scope URIs for downstream APIs (e.g., `https://graph.microsoft.com/Mail.Read`). Short forms like `Mail.Read` work for authorization requests, but fully-qualified URIs are clearer and avoid ambiguity. -</Info> - -### EntraOBOToken Dependency - -The `EntraOBOToken` dependency handles the complete OBO flow automatically. Declare it as a parameter default with the scopes you need, and FastMCP exchanges the user's token for a downstream API token before your function runs. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken -import httpx - -auth_provider = AzureProvider( - client_id="your-client-id", - client_secret="your-client-secret", - tenant_id="your-tenant-id", - base_url="http://localhost:8000", - required_scopes=["mcp-access"], - additional_authorize_scopes=[ - "https://graph.microsoft.com/Mail.Read", - "https://graph.microsoft.com/User.Read", - ], -) - -mcp = FastMCP(name="Email Reader", auth=auth_provider) - -@mcp.tool -async def get_recent_emails( - count: int = 10, - graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), -) -> list[dict]: - """Get the user's recent emails from Microsoft Graph.""" - async with httpx.AsyncClient() as client: - response = await client.get( - f"https://graph.microsoft.com/v1.0/me/messages?$top={count}", - headers={"Authorization": f"Bearer {graph_token}"}, - ) - response.raise_for_status() - data = response.json() - - return [ - {"subject": msg["subject"], "from": msg["from"]["emailAddress"]["address"]} - for msg in data.get("value", []) - ] -``` - -The `graph_token` parameter receives a ready-to-use access token for Microsoft Graph. FastMCP handles the OBO exchange transparently—your function just uses the token to call the API. - -<Warning> -**Scope alignment is critical.** The scopes passed to `EntraOBOToken` must be a subset of the scopes in `additional_authorize_scopes`. If you request a scope during OBO that wasn't included in the initial authorization, the exchange will fail. -</Warning> - -<Tip> -For advanced OBO scenarios, use `CurrentAccessToken()` to get the user's token, then construct an `azure.identity.aio.OnBehalfOfCredential` directly with your Azure credentials. -</Tip> - -<Tip> -For a complete working example of Azure OBO with FastMCP, see [Pamela Fox's blog post on OBO flow for Entra-based MCP servers](https://blog.pamelafox.org/2026/01/using-on-behalf-of-flow-for-entra-based.html). -</Tip> - -## Azure AD B2C - -<VersionBadge version="3.3.0" /> - -Azure AD B2C (Business-to-Consumer) uses different endpoints, scope URIs, and -token issuers than standard Microsoft Entra ID. The `AzureProvider.from_b2c()` -factory handles all of these differences automatically. - -<Warning> -Azure AD B2C does **not** support the On-Behalf-Of (OBO) flow. If you need -OBO for downstream API calls, use `AzureProvider` with standard Entra ID -instead. -</Warning> - -### Quick Start - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider - -auth = AzureProvider.from_b2c( - tenant_name="mytenant", - policy_name="B2C_1_susi", - client_id="00000000-0000-0000-0000-000000000000", - client_secret="my-secret", - required_scopes=["mcp-access"], - base_url="https://myserver.com", -) - -mcp = FastMCP("My App", auth=auth) -``` - -`from_b2c()` derives the following values automatically: - -| Derived value | Formula | -|---|---| -| Authority host | `{tenant_name}.b2clogin.com` | -| Authorization endpoint | `https://{tenant_name}.b2clogin.com/{tenant_name}.onmicrosoft.com/{policy_name}/oauth2/v2.0/authorize` | -| Token endpoint | `https://{tenant_name}.b2clogin.com/{tenant_name}.onmicrosoft.com/{policy_name}/oauth2/v2.0/token` | -| Scope identifier URI | `https://{tenant_name}.onmicrosoft.com/{client_id}` | - -### Token Issuer Validation - -B2C access tokens carry the **tenant GUID** (not the `.onmicrosoft.com` name) -in the `iss` claim, and the exact format varies by policy and custom-domain -configuration. `from_b2c()` therefore **disables issuer validation by -default**; **audience validation still enforces that tokens target the correct -application**. - -Once you have confirmed a successful end-to-end login, read the actual `iss` -value from the decoded claims and enable strict validation: - -```python -auth = AzureProvider.from_b2c( - tenant_name="mytenant", - policy_name="B2C_1_susi", - client_id="00000000-0000-0000-0000-000000000000", - client_secret="my-secret", - required_scopes=["mcp-access"], - base_url="https://myserver.com", - token_issuer="https://mytenant.b2clogin.com/11111111-2222-3333-4444-555555555555/v2.0/", -) -``` - -### Custom Domains - -If your B2C tenant uses a [custom domain](https://learn.microsoft.com/en-us/azure/active-directory-b2c/custom-domain) -(e.g. `auth.mycompany.com` instead of `mytenant.b2clogin.com`), pass it via -`custom_domain`: - -```python -auth = AzureProvider.from_b2c( - tenant_name="mytenant", - policy_name="B2C_1_susi", - client_id="00000000-0000-0000-0000-000000000000", - client_secret="my-secret", - required_scopes=["mcp-access"], - base_url="https://myserver.com", - custom_domain="auth.mycompany.com", -) -``` diff --git a/docs/v3/integrations/chatgpt.mdx b/docs/v3/integrations/chatgpt.mdx deleted file mode 100644 index 23249f92c..000000000 --- a/docs/v3/integrations/chatgpt.mdx +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: ChatGPT 🤝 FastMCP -sidebarTitle: ChatGPT -description: Connect FastMCP servers to ChatGPT in Chat and Deep Research modes -icon: message-smile ---- - -[ChatGPT](https://chatgpt.com/) supports MCP servers through remote HTTP connections in two modes: **Chat mode** for interactive conversations and **Deep Research mode** for comprehensive information retrieval. - -<Tip> -**Developer Mode Required for Chat Mode**: To use MCP servers in regular ChatGPT conversations, you must first enable Developer Mode in your ChatGPT settings. This feature is available for ChatGPT Pro, Team, Enterprise, and Edu users. -</Tip> - -<Note> -OpenAI's official MCP documentation and examples are built with **FastMCP v2**! Learn more from their [MCP documentation](https://platform.openai.com/docs/mcp) and [Developer Mode guide](https://platform.openai.com/docs/guides/developer-mode). -</Note> - -## Build a Server - -First, let's create a simple FastMCP server: - -```python server.py -from fastmcp import FastMCP -import random - -mcp = FastMCP("Demo Server") - -@mcp.tool -def roll_dice(sides: int = 6) -> int: - """Roll a dice with the specified number of sides.""" - return random.randint(1, sides) - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -### Deploy Your Server - -Your server must be accessible from the internet. For development, use `ngrok`: - -<CodeGroup> -```bash Terminal 1 -python server.py -``` - -```bash Terminal 2 -ngrok http 8000 -``` -</CodeGroup> - -Note your public URL (e.g., `https://abc123.ngrok.io`) for the next steps. - -## Chat Mode - -Chat mode lets you use MCP tools directly in ChatGPT conversations. See [OpenAI's Developer Mode guide](https://platform.openai.com/docs/guides/developer-mode) for the latest requirements. - -### Add to ChatGPT - -#### 1. Enable Developer Mode - -1. Open ChatGPT and go to **Settings** → **Connectors** -2. Under **Advanced**, toggle **Developer Mode** to enabled - -#### 2. Create Connector - -1. In **Settings** → **Connectors**, click **Create** -2. Enter: - - **Name**: Your server name - - **Server URL**: `https://your-server.ngrok.io/mcp/` -3. Check **I trust this provider** -4. Add authentication if needed -5. Click **Create** - -<Note> -**Without Developer Mode**: If you don't have search/fetch tools, ChatGPT will reject the server. With Developer Mode enabled, you don't need search/fetch tools for Chat mode. -</Note> - -#### 3. Use in Chat - -1. Start a new chat -2. Click the **+** button → **More** → **Developer Mode** -3. **Enable your MCP server connector** (required - the connector must be explicitly added to each chat) -4. Now you can use your tools: - -Example usage: -- "Roll a 20-sided dice" -- "Roll dice" (uses default 6 sides) - -<Tip> -The connector must be explicitly enabled in each chat session through Developer Mode. Once added, it remains active for the entire conversation. -</Tip> - -### Skip Confirmations - -Use `annotations=ToolAnnotations(readOnlyHint=True)` to skip confirmation prompts for read-only tools: - -```python -from mcp.types import ToolAnnotations - -@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_status() -> str: - """Check system status.""" - return "All systems operational" - -@mcp.tool() # No annotation - ChatGPT may ask for confirmation -def delete_item(id: str) -> str: - """Delete an item.""" - return f"Deleted {id}" -``` - -## Deep Research Mode - -Deep Research mode provides systematic information retrieval with citations. See [OpenAI's MCP documentation](https://platform.openai.com/docs/mcp) for the latest Deep Research specifications. - -<Warning> -**Search and Fetch Required**: Without Developer Mode, ChatGPT will reject any server that doesn't have both `search` and `fetch` tools. Even in Developer Mode, Deep Research only uses these two tools. -</Warning> - -### Tool Implementation - -Deep Research tools must follow this pattern: - -```python -@mcp.tool() -def search(query: str) -> dict: - """ - Search for records matching the query. - Must return {"ids": [list of string IDs]} - """ - # Your search logic - matching_ids = ["id1", "id2", "id3"] - return {"ids": matching_ids} - -@mcp.tool() -def fetch(id: str) -> dict: - """ - Fetch a complete record by ID. - Return the full record data for ChatGPT to analyze. - """ - # Your fetch logic - return { - "id": id, - "title": "Record Title", - "content": "Full record content...", - "metadata": {"author": "Jane Doe", "date": "2024"} - } -``` - -### Using Deep Research - -1. Ensure your server is added to ChatGPT's connectors (same as Chat mode) -2. Start a new chat -3. Click **+** → **Deep Research** -4. Select your MCP server as a source -5. Ask research questions - -ChatGPT will use your `search` and `fetch` tools to find and cite relevant information. diff --git a/docs/v3/integrations/claude-code.mdx b/docs/v3/integrations/claude-code.mdx deleted file mode 100644 index 8098ff51e..000000000 --- a/docs/v3/integrations/claude-code.mdx +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: Claude Code 🤝 FastMCP -sidebarTitle: Claude Code -description: Install and use FastMCP servers in Claude Code -icon: message-smile ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" -import { LocalFocusTip } from "/snippets/local-focus.mdx" - -<LocalFocusTip /> - -[Claude Code](https://docs.anthropic.com/en/docs/claude-code) supports MCP servers through multiple transport methods including STDIO, SSE, and HTTP, allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers. - -## Requirements - -This integration uses STDIO transport to run your FastMCP server locally. For remote deployments, you can run your FastMCP server with HTTP or SSE transport and configure it directly using Claude Code's built-in MCP management commands. - -## Create a Server - -The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run() -``` - -## Install the Server - -### FastMCP CLI -<VersionBadge version="2.10.3" /> - -The easiest way to install a FastMCP server in Claude Code is using the `fastmcp install claude-code` command. This automatically handles the configuration, dependency management, and calls Claude Code's built-in MCP management system. - -```bash -fastmcp install claude-code server.py -``` - -The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: - -```bash -# These are equivalent if your server object is named 'mcp' -fastmcp install claude-code server.py -fastmcp install claude-code server.py:mcp - -# Use explicit object name if your server has a different name -fastmcp install claude-code server.py:my_custom_server -``` - -The command will automatically configure the server with Claude Code's `claude mcp add` command. - -#### Dependencies - -FastMCP provides flexible dependency management options for your Claude Code servers: - -**Individual packages**: Use the `--with` flag to specify packages your server needs. You can use this flag multiple times: - -```bash -fastmcp install claude-code server.py --with pandas --with requests -``` - -**Requirements file**: If you maintain a `requirements.txt` file with all your dependencies, use `--with-requirements` to install them: - -```bash -fastmcp install claude-code server.py --with-requirements requirements.txt -``` - -**Editable packages**: For local packages under development, use `--with-editable` to install them in editable mode: - -```bash -fastmcp install claude-code server.py --with-editable ./my-local-package -``` - -Alternatively, you can use a `fastmcp.json` configuration file (recommended): - -```json fastmcp.json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - "dependencies": ["pandas", "requests"] - } -} -``` - - -#### Python Version and Project Configuration - -Control the Python environment for your server with these options: - -**Python version**: Use `--python` to specify which Python version your server requires. This ensures compatibility when your server needs specific Python features: - -```bash -fastmcp install claude-code server.py --python 3.11 -``` - -**Project directory**: Use `--project` to run your server within a specific project context. This tells `uv` to use the project's configuration files and virtual environment: - -```bash -fastmcp install claude-code server.py --project /path/to/my-project -``` - -#### Environment Variables - -If your server needs environment variables (like API keys), you must include them: - -```bash -fastmcp install claude-code server.py --server-name "Weather Server" \ - --env API_KEY=your-api-key \ - --env DEBUG=true -``` - -Or load them from a `.env` file: - -```bash -fastmcp install claude-code server.py --server-name "Weather Server" --env-file .env -``` - -<Warning> -**Claude Code must be installed**. The integration looks for the Claude Code CLI at the default installation location (`~/.claude/local/claude`) and uses the `claude mcp add` command to register servers. -</Warning> - -### Manual Configuration - -For more control over the configuration, you can manually use Claude Code's built-in MCP management commands. This gives you direct control over how your server is launched: - -```bash -# Add a server with custom configuration -claude mcp add dice-roller -- uv run --with fastmcp fastmcp run server.py - -# Add with environment variables -claude mcp add weather-server -e API_KEY=secret -e DEBUG=true -- uv run --with fastmcp fastmcp run server.py - -# Add with specific scope (local, user, or project) -claude mcp add my-server --scope user -- uv run --with fastmcp fastmcp run server.py -``` - -You can also manually specify Python versions and project directories in your Claude Code commands: - -```bash -# With specific Python version -claude mcp add ml-server -- uv run --python 3.11 --with fastmcp fastmcp run server.py - -# Within a project directory -claude mcp add project-server -- uv run --project /path/to/project --with fastmcp fastmcp run server.py -``` - -## Using the Server - -Once your server is installed, you can start using your FastMCP server with Claude Code. - -Try asking Claude something like: - -> "Roll some dice for me" - -Claude will automatically detect your `roll_dice` tool and use it to fulfill your request, returning something like: - -> I'll roll some dice for you! Here are your results: [4, 2, 6] -> -> You rolled three dice and got a 4, a 2, and a 6! - -Claude Code can now access all the tools, resources, and prompts you've defined in your FastMCP server. - -If your server provides resources, you can reference them with `@` mentions using the format `@server:protocol://resource/path`. If your server provides prompts, you can use them as slash commands with `/mcp__servername__promptname`. \ No newline at end of file diff --git a/docs/v3/integrations/claude-desktop.mdx b/docs/v3/integrations/claude-desktop.mdx deleted file mode 100644 index 4478bcc37..000000000 --- a/docs/v3/integrations/claude-desktop.mdx +++ /dev/null @@ -1,299 +0,0 @@ ---- -title: Claude Desktop 🤝 FastMCP -sidebarTitle: Claude Desktop -description: Connect FastMCP servers to Claude Desktop -icon: message-smile ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" -import { LocalFocusTip } from "/snippets/local-focus.mdx" - -<LocalFocusTip /> - -[Claude Desktop](https://www.claude.com/download) supports MCP servers through local STDIO connections and remote servers (beta), allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers. - -<Note> -Remote MCP server support is currently in beta and available for users on Claude Pro, Max, Team, and Enterprise plans (as of June 2025). Most users will still need to use local STDIO connections. -</Note> - -<Note> -This guide focuses specifically on using FastMCP servers with Claude Desktop. For general Claude Desktop MCP setup and official examples, see the [official Claude Desktop quickstart guide](https://modelcontextprotocol.io/quickstart/user). -</Note> - - -## Requirements - -Claude Desktop traditionally requires MCP servers to run locally using STDIO transport, where your server communicates with Claude through standard input/output rather than HTTP. However, users on certain plans now have access to remote server support as well. - -<Tip> -If you don't have access to remote server support or need to connect to remote servers, you can create a **proxy server** that runs locally via STDIO and forwards requests to remote HTTP servers. See the [Proxy Servers](#proxy-servers) section below. -</Tip> - -## Create a Server - -The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run() -``` - -## Install the Server - -### FastMCP CLI -<VersionBadge version="2.10.3" /> - -The easiest way to install a FastMCP server in Claude Desktop is using the `fastmcp install claude-desktop` command. This automatically handles the configuration and dependency management. - -<Tip> -Prior to version 2.10.3, Claude Desktop could be managed by running `fastmcp install <path>` without specifying the client. -</Tip> - -```bash -fastmcp install claude-desktop server.py -``` - -The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: - -```bash -# These are equivalent if your server object is named 'mcp' -fastmcp install claude-desktop server.py -fastmcp install claude-desktop server.py:mcp - -# Use explicit object name if your server has a different name -fastmcp install claude-desktop server.py:my_custom_server -``` - -After installation, restart Claude Desktop completely. You should see a hammer icon (🔨) in the bottom left of the input box, indicating that MCP tools are available. - -#### Dependencies - -FastMCP provides several ways to manage your server's dependencies when installing in Claude Desktop: - -**Individual packages**: Use the `--with` flag to specify packages your server needs. You can use this flag multiple times: - -```bash -fastmcp install claude-desktop server.py --with pandas --with requests -``` - -**Requirements file**: If you have a `requirements.txt` file listing all your dependencies, use `--with-requirements` to install them all at once: - -```bash -fastmcp install claude-desktop server.py --with-requirements requirements.txt -``` - -**Editable packages**: For local packages in development, use `--with-editable` to install them in editable mode: - -```bash -fastmcp install claude-desktop server.py --with-editable ./my-local-package -``` - -Alternatively, you can use a `fastmcp.json` configuration file (recommended): - -```json fastmcp.json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - "dependencies": ["pandas", "requests"] - } -} -``` - - -#### Python Version and Project Directory - -FastMCP allows you to control the Python environment for your server: - -**Python version**: Use `--python` to specify which Python version your server should run with. This is particularly useful when your server requires a specific Python version: - -```bash -fastmcp install claude-desktop server.py --python 3.11 -``` - -**Project directory**: Use `--project` to run your server within a specific project directory. This ensures that `uv` will discover all `pyproject.toml`, `uv.toml`, and `.python-version` files from that project: - -```bash -fastmcp install claude-desktop server.py --project /path/to/my-project -``` - -When you specify a project directory, all relative paths in your server will be resolved from that directory, and the project's virtual environment will be used. - -#### Environment Variables - -<Warning> -Claude Desktop runs servers in a completely isolated environment with no access to your shell environment or locally installed applications. You must explicitly pass any environment variables your server needs. -</Warning> - -If your server needs environment variables (like API keys), you must include them: - -```bash -fastmcp install claude-desktop server.py --server-name "Weather Server" \ - --env API_KEY=your-api-key \ - --env DEBUG=true -``` - -Or load them from a `.env` file: - -```bash -fastmcp install claude-desktop server.py --server-name "Weather Server" --env-file .env -``` -<Warning> -- **`uv` must be installed and available in your system PATH**. Claude Desktop runs in its own isolated environment and needs `uv` to manage dependencies. -- **On macOS, it is recommended to install `uv` globally with Homebrew** so that Claude Desktop will detect it: `brew install uv`. Installing `uv` with other methods may not make it accessible to Claude Desktop. -</Warning> - - -### Manual Configuration - -For more control over the configuration, you can manually edit Claude Desktop's configuration file. You can open the configuration file from Claude's developer settings, or find it in the following locations: -- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` - -The configuration file is a JSON object with a `mcpServers` key, which contains the configuration for each MCP server. - -```json -{ - "mcpServers": { - "dice-roller": { - "command": "python", - "args": ["path/to/your/server.py"] - } - } -} -``` - -After updating the configuration file, restart Claude Desktop completely. Look for the hammer icon (🔨) to confirm your server is loaded. - -#### Dependencies - -If your server has dependencies, you can use `uv` or another package manager to set up the environment. - - -When manually configuring dependencies, the recommended approach is to use `uv` with FastMCP. The configuration uses `uv run` to create an isolated environment with your specified packages: - -```json -{ - "mcpServers": { - "dice-roller": { - "command": "uv", - "args": [ - "run", - "--with", "fastmcp", - "--with", "pandas", - "--with", "requests", - "fastmcp", - "run", - "path/to/your/server.py" - ] - } - } -} -``` - -You can also manually specify Python versions and project directories in your configuration. Add `--python` to use a specific Python version, or `--project` to run within a project directory: - -```json -{ - "mcpServers": { - "dice-roller": { - "command": "uv", - "args": [ - "run", - "--python", "3.11", - "--project", "/path/to/project", - "--with", "fastmcp", - "fastmcp", - "run", - "path/to/your/server.py" - ] - } - } -} -``` - -The order of arguments matters: Python version and project settings come before package specifications, which come before the actual command to run. - -<Warning> -- **`uv` must be installed and available in your system PATH**. Claude Desktop runs in its own isolated environment and needs `uv` to manage dependencies. -- **On macOS, it is recommended to install `uv` globally with Homebrew** so that Claude Desktop will detect it: `brew install uv`. Installing `uv` with other methods may not make it accessible to Claude Desktop. -</Warning> - -#### Environment Variables - -You can also specify environment variables in the configuration: - -```json -{ - "mcpServers": { - "weather-server": { - "command": "python", - "args": ["path/to/weather_server.py"], - "env": { - "API_KEY": "your-api-key", - "DEBUG": "true" - } - } - } -} -``` -<Warning> -Claude Desktop runs servers in a completely isolated environment with no access to your shell environment or locally installed applications. You must explicitly pass any environment variables your server needs. -</Warning> - - -## Remote Servers - - -Users on Claude Pro, Max, Team, and Enterprise plans have first-class remote server support via integrations. For other users, or as an alternative approach, FastMCP can create a proxy server that forwards requests to a remote HTTP server. You can install the proxy server in Claude Desktop. - -Create a proxy server that connects to a remote HTTP server: - -```python proxy_server.py -from fastmcp.server import create_proxy - -# Create a proxy to a remote server -proxy = create_proxy( - "https://example.com/mcp/sse", - name="Remote Server Proxy" -) - -if __name__ == "__main__": - proxy.run() # Runs via STDIO for Claude Desktop -``` - -### Authentication - -For authenticated remote servers, create an authenticated client following the guidance in the [client auth documentation](/clients/auth/bearer) and pass it to the proxy: - -```python auth_proxy_server.py {7} -from fastmcp import Client -from fastmcp.client.auth import BearerAuth -from fastmcp.server import create_proxy - -# Create authenticated client -client = Client( - "https://api.example.com/mcp/sse", - auth=BearerAuth(token="your-access-token") -) - -# Create proxy using the authenticated client -proxy = create_proxy(client, name="Authenticated Proxy") - -if __name__ == "__main__": - proxy.run() -``` - diff --git a/docs/v3/integrations/cursor-install-mcp.png b/docs/v3/integrations/cursor-install-mcp.png deleted file mode 100644 index 5681d70d7..000000000 Binary files a/docs/v3/integrations/cursor-install-mcp.png and /dev/null differ diff --git a/docs/v3/integrations/cursor.mdx b/docs/v3/integrations/cursor.mdx deleted file mode 100644 index da0744ee0..000000000 --- a/docs/v3/integrations/cursor.mdx +++ /dev/null @@ -1,284 +0,0 @@ ---- -title: Cursor 🤝 FastMCP -sidebarTitle: Cursor -description: Install and use FastMCP servers in Cursor -icon: message-smile ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" -import { LocalFocusTip } from "/snippets/local-focus.mdx" - -<LocalFocusTip /> - -[Cursor](https://www.cursor.com/) supports MCP servers through multiple transport methods including STDIO, SSE, and Streamable HTTP, allowing you to extend Cursor's AI assistant with custom tools, resources, and prompts from your FastMCP servers. - -## Requirements - -This integration uses STDIO transport to run your FastMCP server locally. For remote deployments, you can run your FastMCP server with HTTP or SSE transport and configure it directly in Cursor's settings. - -## Create a Server - -The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run() -``` - -## Install the Server - -### FastMCP CLI -<VersionBadge version="2.10.3" /> - -The easiest way to install a FastMCP server in Cursor is using the `fastmcp install cursor` command. This automatically handles the configuration, dependency management, and opens Cursor with a deeplink to install the server. - -```bash -fastmcp install cursor server.py -``` - -#### Workspace Installation -<VersionBadge version="2.12.0" /> - -By default, FastMCP installs servers globally for Cursor. You can also install servers to project-specific workspaces using the `--workspace` flag: - -```bash -# Install to current directory's .cursor/ folder -fastmcp install cursor server.py --workspace . - -# Install to specific workspace -fastmcp install cursor server.py --workspace /path/to/project -``` - -This creates a `.cursor/mcp.json` configuration file in the specified workspace directory, allowing different projects to have their own MCP server configurations. - -The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: - -```bash -# These are equivalent if your server object is named 'mcp' -fastmcp install cursor server.py -fastmcp install cursor server.py:mcp - -# Use explicit object name if your server has a different name -fastmcp install cursor server.py:my_custom_server -``` - -After running the command, Cursor will open automatically and prompt you to install the server. The command will be `uv`, which is expected as this is a Python STDIO server. Click "Install" to confirm: - -![Cursor install prompt](./cursor-install-mcp.png) - -#### Dependencies - -FastMCP offers multiple ways to manage dependencies for your Cursor servers: - -**Individual packages**: Use the `--with` flag to specify packages your server needs. You can use this flag multiple times: - -```bash -fastmcp install cursor server.py --with pandas --with requests -``` - -**Requirements file**: For projects with a `requirements.txt` file, use `--with-requirements` to install all dependencies at once: - -```bash -fastmcp install cursor server.py --with-requirements requirements.txt -``` - -**Editable packages**: When developing local packages, use `--with-editable` to install them in editable mode: - -```bash -fastmcp install cursor server.py --with-editable ./my-local-package -``` - -Alternatively, you can use a `fastmcp.json` configuration file (recommended): - -```json fastmcp.json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - "dependencies": ["pandas", "requests"] - } -} -``` - - -#### Python Version and Project Configuration - -Control your server's Python environment with these options: - -**Python version**: Use `--python` to specify which Python version your server should use. This is essential when your server requires specific Python features: - -```bash -fastmcp install cursor server.py --python 3.11 -``` - -**Project directory**: Use `--project` to run your server within a specific project context. This ensures `uv` discovers all project configuration files and uses the correct virtual environment: - -```bash -fastmcp install cursor server.py --project /path/to/my-project -``` - -#### Environment Variables - -<Warning> -Cursor runs servers in a completely isolated environment with no access to your shell environment or locally installed applications. You must explicitly pass any environment variables your server needs. -</Warning> - -If your server needs environment variables (like API keys), you must include them: - -```bash -fastmcp install cursor server.py --server-name "Weather Server" \ - --env API_KEY=your-api-key \ - --env DEBUG=true -``` - -Or load them from a `.env` file: - -```bash -fastmcp install cursor server.py --server-name "Weather Server" --env-file .env -``` - -<Warning> -**`uv` must be installed and available in your system PATH**. Cursor runs in its own isolated environment and needs `uv` to manage dependencies. -</Warning> - -### Generate MCP JSON - -<Note> -**Use the first-class integration above for the best experience.** The MCP JSON generation is useful for advanced use cases, manual configuration, or integration with other tools. -</Note> - -You can generate MCP JSON configuration for manual use: - -```bash -# Generate configuration and output to stdout -fastmcp install mcp-json server.py --server-name "Dice Roller" --with pandas - -# Copy configuration to clipboard for easy pasting -fastmcp install mcp-json server.py --server-name "Dice Roller" --copy -``` - -This generates the standard `mcpServers` configuration format that can be used with any MCP-compatible client. - -### Manual Configuration - -For more control over the configuration, you can manually edit Cursor's configuration file. The configuration file is located at: -- **All platforms**: `~/.cursor/mcp.json` - -The configuration file is a JSON object with a `mcpServers` key, which contains the configuration for each MCP server. - -```json -{ - "mcpServers": { - "dice-roller": { - "command": "python", - "args": ["path/to/your/server.py"] - } - } -} -``` - -After updating the configuration file, your server should be available in Cursor. - -#### Dependencies - -If your server has dependencies, you can use `uv` or another package manager to set up the environment. - -When manually configuring dependencies, the recommended approach is to use `uv` with FastMCP. The configuration should use `uv run` to create an isolated environment with your specified packages: - -```json -{ - "mcpServers": { - "dice-roller": { - "command": "uv", - "args": [ - "run", - "--with", "fastmcp", - "--with", "pandas", - "--with", "requests", - "fastmcp", - "run", - "path/to/your/server.py" - ] - } - } -} -``` - -You can also manually specify Python versions and project directories in your configuration: - -```json -{ - "mcpServers": { - "dice-roller": { - "command": "uv", - "args": [ - "run", - "--python", "3.11", - "--project", "/path/to/project", - "--with", "fastmcp", - "fastmcp", - "run", - "path/to/your/server.py" - ] - } - } -} -``` - -Note that the order of arguments is important: Python version and project settings should come before package specifications. - -<Warning> -**`uv` must be installed and available in your system PATH**. Cursor runs in its own isolated environment and needs `uv` to manage dependencies. -</Warning> - -#### Environment Variables - -You can also specify environment variables in the configuration: - -```json -{ - "mcpServers": { - "weather-server": { - "command": "python", - "args": ["path/to/weather_server.py"], - "env": { - "API_KEY": "your-api-key", - "DEBUG": "true" - } - } - } -} -``` - -<Warning> -Cursor runs servers in a completely isolated environment with no access to your shell environment or locally installed applications. You must explicitly pass any environment variables your server needs. -</Warning> - -## Using the Server - -Once your server is installed, you can start using your FastMCP server with Cursor's AI assistant. - -Try asking Cursor something like: - -> "Roll some dice for me" - -Cursor will automatically detect your `roll_dice` tool and use it to fulfill your request, returning something like: - -> 🎲 Here are your dice rolls: 4, 6, 4 -> -> You rolled 3 dice with a total of 14! The 6 was a nice high roll there! - -The AI assistant can now access all the tools, resources, and prompts you've defined in your FastMCP server. diff --git a/docs/v3/integrations/descope.mdx b/docs/v3/integrations/descope.mdx deleted file mode 100644 index bfb6cd9c8..000000000 --- a/docs/v3/integrations/descope.mdx +++ /dev/null @@ -1,113 +0,0 @@ ---- -title: Descope 🤝 FastMCP -sidebarTitle: Descope -description: Secure your FastMCP server with Descope -icon: shield-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<VersionBadge version="2.12.4" /> - -This guide shows you how to secure your FastMCP server using [**Descope**](https://www.descope.com), a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where Descope handles user login and your FastMCP server validates the tokens. - -## Configuration - -### Prerequisites - -Before you begin, you will need: - -1. To [sign up](https://www.descope.com/sign-up) for a Free Forever Descope account -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:3000`) - -### Step 1: Configure Descope - -<Steps> -<Step title="Create an MCP Server"> - 1. Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console, and create a new MCP Server. - 2. Give the MCP server a name and description. - 3. Ensure that **Dynamic Client Registration (DCR)** is enabled. Then click **Create**. - 4. Once you've created the MCP Server, note your Well-Known URL. - - - <Warning> - DCR is required for FastMCP clients to automatically register with your authentication server. - </Warning> -</Step> - -<Step title="Note Your Well-Known URL"> - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers): - ``` - Well-Known URL: https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration - ``` -</Step> -</Steps> - -### Step 2: Environment Setup - -Create a `.env` file with your Descope configuration: - -```bash -DESCOPE_CONFIG_URL=https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration # Your Descope Well-Known URL -SERVER_URL=http://localhost:3000 # Your server's base URL -``` - -### Step 3: FastMCP Configuration - -Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.descope import DescopeProvider - -# The DescopeProvider automatically discovers Descope endpoints -# and configures JWT token validation -auth_provider = DescopeProvider( - config_url="https://.../.well-known/openid-configuration", # Your MCP Server .well-known URL - base_url=SERVER_URL, # Your server's public URL -) - -# Create FastMCP server with auth -mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider) - -``` - -## Testing - -To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the environment variables with your actual values!), you can run the following command: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Now, you can use a FastMCP client to test that you can reach your server after authenticating: - -```python -from fastmcp import Client -import asyncio - -async def main(): - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - assert await client.ping() - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## Production Configuration - -For production deployments, load configuration from environment variables: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.descope import DescopeProvider - -# Load configuration from environment variables -auth = DescopeProvider( - config_url=os.environ.get("DESCOPE_CONFIG_URL"), - base_url=os.environ.get("BASE_URL", "https://your-server.com") -) - -mcp = FastMCP(name="My Descope Protected Server", auth=auth) -``` diff --git a/docs/v3/integrations/discord.mdx b/docs/v3/integrations/discord.mdx deleted file mode 100644 index 5d6c643b7..000000000 --- a/docs/v3/integrations/discord.mdx +++ /dev/null @@ -1,183 +0,0 @@ ---- -title: Discord OAuth 🤝 FastMCP -sidebarTitle: Discord -description: Secure your FastMCP server with Discord OAuth -icon: discord ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.13.2" /> - -This guide shows you how to secure your FastMCP server using **Discord OAuth**. Since Discord doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Discord's traditional OAuth with MCP's authentication requirements. - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. A **[Discord Account](https://discord.com/)** with access to create applications -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Create a Discord Application - -Create an application in the Discord Developer Portal to get the credentials needed for authentication: - -<Steps> -<Step title="Navigate to Discord Developer Portal"> - Go to the [Discord Developer Portal](https://discord.com/developers/applications). - - Click **"New Application"** and give it a name users will recognize (e.g., "My FastMCP Server"). -</Step> - -<Step title="Configure OAuth2 Settings"> - In the left sidebar, click **"OAuth2"**. - - In the **Redirects** section, click **"Add Redirect"** and enter your callback URL: - - For development: `http://localhost:8000/auth/callback` - - For production: `https://your-domain.com/auth/callback` - - <Warning> - The redirect URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. Discord allows `http://localhost` URLs for development. For production, use HTTPS. - </Warning> -</Step> - -<Step title="Save Your Credentials"> - On the same OAuth2 page, you'll find: - - - **Client ID**: A numeric string like `12345` - - **Client Secret**: Click "Reset Secret" to generate one - - <Tip> - Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. - </Tip> -</Step> -</Steps> - -### Step 2: FastMCP Configuration - -Create your FastMCP server using the `DiscordProvider`, which handles Discord's OAuth flow automatically: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.discord import DiscordProvider - -auth_provider = DiscordProvider( - client_id="12345", # Your Discord Application Client ID - client_secret="your-client-secret", # Your Discord OAuth Client Secret - base_url="http://localhost:8000", # Must match your OAuth configuration -) - -mcp = FastMCP(name="Discord Secured App", auth=auth_provider) - -@mcp.tool -async def get_user_info() -> dict: - """Returns information about the authenticated Discord user.""" - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - return { - "discord_id": token.claims.get("sub"), - "username": token.claims.get("username"), - "avatar": token.claims.get("avatar"), - } -``` - -## Testing - -### Running the Server - -Start your FastMCP server with HTTP transport to enable OAuth flows: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Your server is now running and protected by Discord OAuth authentication. - -### Testing with a Client - -Create a test client that authenticates with your Discord-protected server: - -```python test_client.py -from fastmcp import Client -import asyncio - -async def main(): - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - print("✓ Authenticated with Discord!") - - result = await client.call_tool("get_user_info") - print(f"Discord user: {result['username']}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to Discord's authorization page -2. Sign in with your Discord account and authorize the app -3. After authorization, you'll be redirected back -4. The client receives the token and can make authenticated requests - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. -</Info> - -## Discord Scopes - -Discord OAuth supports several scopes for accessing different types of user data: - -| Scope | Description | -|-------|-------------| -| `identify` | Access username, avatar, and discriminator (default) | -| `email` | Access the user's email address | -| `guilds` | Access the user's list of servers | -| `guilds.join` | Ability to add the user to a server | - -To request additional scopes: - -```python -auth_provider = DiscordProvider( - client_id="...", - client_secret="...", - base_url="http://localhost:8000", - required_scopes=["identify", "email"], -) -``` - -## Production Configuration - -For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.discord import DiscordProvider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -auth_provider = DiscordProvider( - client_id="12345", - client_secret=os.environ["DISCORD_CLIENT_SECRET"], - base_url="https://your-production-domain.com", - - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production Discord App", auth=auth_provider) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> diff --git a/docs/v3/integrations/eunomia-authorization.mdx b/docs/v3/integrations/eunomia-authorization.mdx deleted file mode 100644 index 2fd2ca4a5..000000000 --- a/docs/v3/integrations/eunomia-authorization.mdx +++ /dev/null @@ -1,129 +0,0 @@ ---- -title: Eunomia Authorization 🤝 FastMCP -sidebarTitle: Eunomia Auth -description: Add policy-based authorization to your FastMCP servers with Eunomia -icon: shield-check ---- - -Add **policy-based authorization** to your FastMCP servers with one-line code addition with the **[Eunomia][eunomia-github] authorization middleware**. - -Control which tools, resources and prompts MCP clients can view and execute on your server. Define dynamic JSON-based policies and obtain a comprehensive audit log of all access attempts and violations. - -## How it Works - -Exploiting FastMCP's [Middleware][fastmcp-middleware], the Eunomia middleware intercepts all MCP requests to your server and automatically maps MCP methods to authorization checks. - -### Listing Operations - -The middleware behaves as a filter for listing operations (`tools/list`, `resources/list`, `prompts/list`), hiding to the client components that are not authorized by the defined policies. - -```mermaid -sequenceDiagram - participant MCPClient as MCP Client - participant EunomiaMiddleware as Eunomia Middleware - participant MCPServer as FastMCP Server - participant EunomiaServer as Eunomia Server - - MCPClient->>EunomiaMiddleware: MCP Listing Request (e.g., tools/list) - EunomiaMiddleware->>MCPServer: MCP Listing Request - MCPServer-->>EunomiaMiddleware: MCP Listing Response - EunomiaMiddleware->>EunomiaServer: Authorization Checks - EunomiaServer->>EunomiaMiddleware: Authorization Decisions - EunomiaMiddleware-->>MCPClient: Filtered MCP Listing Response -``` - -### Execution Operations - -The middleware behaves as a firewall for execution operations (`tools/call`, `resources/read`, `prompts/get`), blocking operations that are not authorized by the defined policies. - -```mermaid -sequenceDiagram - participant MCPClient as MCP Client - participant EunomiaMiddleware as Eunomia Middleware - participant MCPServer as FastMCP Server - participant EunomiaServer as Eunomia Server - - MCPClient->>EunomiaMiddleware: MCP Execution Request (e.g., tools/call) - EunomiaMiddleware->>EunomiaServer: Authorization Check - EunomiaServer->>EunomiaMiddleware: Authorization Decision - EunomiaMiddleware-->>MCPClient: MCP Unauthorized Error (if denied) - EunomiaMiddleware->>MCPServer: MCP Execution Request (if allowed) - MCPServer-->>EunomiaMiddleware: MCP Execution Response (if allowed) - EunomiaMiddleware-->>MCPClient: MCP Execution Response (if allowed) -``` - -## Add Authorization to Your Server - -<Note> -Eunomia is an AI-specific authorization server that handles policy decisions. The server runs embedded within your MCP server by default for a zero-effort configuration, but can alternatively be run remotely for centralized policy decisions. - -</Note> - -### Create a Server with Authorization - -First, install the `eunomia-mcp` package: - -```bash -pip install eunomia-mcp -``` - -Then create a FastMCP server and add the Eunomia middleware in one line: - -```python server.py -from fastmcp import FastMCP -from eunomia_mcp import create_eunomia_middleware - -# Create your FastMCP server -mcp = FastMCP("Secure MCP Server 🔒") - -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - -# Add middleware to your server -middleware = create_eunomia_middleware(policy_file="mcp_policies.json") -mcp.add_middleware(middleware) - -if __name__ == "__main__": - mcp.run() -``` - -### Configure Access Policies - -Use the `eunomia-mcp` CLI in your terminal to manage your authorization policies: - -```bash -# Create a default policy file -eunomia-mcp init - -# Or create a policy file customized for your FastMCP server -eunomia-mcp init --custom-mcp "app.server:mcp" -``` - -This creates `mcp_policies.json` file that you can further edit to your access control needs. - -```bash -# Once edited, validate your policy file -eunomia-mcp validate mcp_policies.json -``` - -### Run the Server - -Start your FastMCP server normally: - -```bash -python server.py -``` - -The middleware will now intercept all MCP requests and check them against your policies. Requests include agent identification through headers like `X-Agent-ID`, `X-User-ID`, `User-Agent`, or `Authorization` and an automatic mapping of MCP methods to authorization resources and actions. - -<Tip> - For detailed policy configuration, custom authentication, and remote - deployments, visit the [Eunomia MCP Middleware - repository][eunomia-mcp-github]. -</Tip> - -[eunomia-github]: https://github.com/whataboutyou-ai/eunomia -[eunomia-mcp-github]: https://github.com/whataboutyou-ai/eunomia/tree/main/pkgs/extensions/mcp -[fastmcp-middleware]: /servers/middleware diff --git a/docs/v3/integrations/fastapi.mdx b/docs/v3/integrations/fastapi.mdx deleted file mode 100644 index 83aa924f8..000000000 --- a/docs/v3/integrations/fastapi.mdx +++ /dev/null @@ -1,445 +0,0 @@ ---- -title: FastAPI 🤝 FastMCP -sidebarTitle: FastAPI -description: Integrate FastMCP with FastAPI applications -icon: bolt ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -FastMCP provides two powerful ways to integrate with FastAPI applications: - -1. **[Generate an MCP server FROM your FastAPI app](#generating-an-mcp-server)** - Convert existing API endpoints into MCP tools -2. **[Mount an MCP server INTO your FastAPI app](#mounting-an-mcp-server)** - Add MCP functionality to your web application - -<Note> -When generating an MCP server from FastAPI, FastMCP uses OpenAPIProvider (v3.0.0+) under the hood to source tools from your FastAPI app's OpenAPI spec. See [Providers](/servers/providers/overview) to understand how FastMCP sources components. -</Note> - - -<Tip> -Generating MCP servers from OpenAPI is a great way to get started with FastMCP, but in practice LLMs achieve **significantly better performance** with well-designed and curated MCP servers than with auto-converted OpenAPI servers. This is especially true for complex APIs with many endpoints and parameters. - -We recommend using the FastAPI integration for bootstrapping and prototyping, not for mirroring your API to LLM clients. See the post [Stop Converting Your REST APIs to MCP](https://www.jlowin.dev/blog/stop-converting-rest-apis-to-mcp) for more details. -</Tip> - - -<Note> -FastMCP does *not* include FastAPI as a dependency; you must install it separately to use this integration. -</Note> - -## Example FastAPI Application - -Throughout this guide, we'll use this e-commerce API as our example (click the `Copy` button to copy it for use with other code blocks): - -```python [expandable] -# Copy this FastAPI server into other code blocks in this guide - -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel - -# Models -class Product(BaseModel): - name: str - price: float - category: str - description: str | None = None - -class ProductResponse(BaseModel): - id: int - name: str - price: float - category: str - description: str | None = None - -# Create FastAPI app -app = FastAPI(title="E-commerce API", version="1.0.0") - -# In-memory database -products_db = { - 1: ProductResponse( - id=1, name="Laptop", price=999.99, category="Electronics" - ), - 2: ProductResponse( - id=2, name="Mouse", price=29.99, category="Electronics" - ), - 3: ProductResponse( - id=3, name="Desk Chair", price=299.99, category="Furniture" - ), -} -next_id = 4 - -@app.get("/products", response_model=list[ProductResponse]) -def list_products( - category: str | None = None, - max_price: float | None = None, -) -> list[ProductResponse]: - """List all products with optional filtering.""" - products = list(products_db.values()) - if category: - products = [p for p in products if p.category == category] - if max_price: - products = [p for p in products if p.price <= max_price] - return products - -@app.get("/products/{product_id}", response_model=ProductResponse) -def get_product(product_id: int): - """Get a specific product by ID.""" - if product_id not in products_db: - raise HTTPException(status_code=404, detail="Product not found") - return products_db[product_id] - -@app.post("/products", response_model=ProductResponse) -def create_product(product: Product): - """Create a new product.""" - global next_id - product_response = ProductResponse(id=next_id, **product.model_dump()) - products_db[next_id] = product_response - next_id += 1 - return product_response - -@app.put("/products/{product_id}", response_model=ProductResponse) -def update_product(product_id: int, product: Product): - """Update an existing product.""" - if product_id not in products_db: - raise HTTPException(status_code=404, detail="Product not found") - products_db[product_id] = ProductResponse( - id=product_id, - **product.model_dump(), - ) - return products_db[product_id] - -@app.delete("/products/{product_id}") -def delete_product(product_id: int): - """Delete a product.""" - if product_id not in products_db: - raise HTTPException(status_code=404, detail="Product not found") - del products_db[product_id] - return {"message": "Product deleted"} -``` - -<Tip> -All subsequent code examples in this guide assume you have the above FastAPI application code already defined. Each example builds upon this base application, `app`. -</Tip> - -## Generating an MCP Server - -<VersionBadge version="2.0.0" /> - -One of the most common ways to bootstrap an MCP server is to generate it from an existing FastAPI application. FastMCP will expose your FastAPI endpoints as MCP components (tools, by default) in order to expose your API to LLM clients. - - - -### Basic Conversion - -Convert the FastAPI app to an MCP server with a single line: - -```python {5} -# Assumes the FastAPI app from above is already defined -from fastmcp import FastMCP - -# Convert to MCP server -mcp = FastMCP.from_fastapi(app=app) - -if __name__ == "__main__": - mcp.run() -``` - -### Adding Components - -Your converted MCP server is a full FastMCP instance, meaning you can add new tools, resources, and other components to it just like you would with any other FastMCP instance. - -```python {8-11} -# Assumes the FastAPI app from above is already defined -from fastmcp import FastMCP - -# Convert to MCP server -mcp = FastMCP.from_fastapi(app=app) - -# Add a new tool -@mcp.tool -def get_product(product_id: int) -> ProductResponse: - """Get a product by ID.""" - return products_db[product_id] - -# Run the MCP server -if __name__ == "__main__": - mcp.run() -``` - - - - - -### Interacting with the MCP Server - -Once you've converted your FastAPI app to an MCP server, you can interact with it using the FastMCP client to test functionality before deploying it to an LLM-based application. - -```python {3, } -# Assumes the FastAPI app from above is already defined -from fastmcp import FastMCP -from fastmcp.client import Client -import asyncio - -# Convert to MCP server -mcp = FastMCP.from_fastapi(app=app) - -async def demo(): - async with Client(mcp) as client: - # List available tools - tools = await client.list_tools() - print(f"Available tools: {[t.name for t in tools]}") - - # Create a product - result = await client.call_tool( - "create_product_products_post", - { - "name": "Wireless Keyboard", - "price": 79.99, - "category": "Electronics", - "description": "Bluetooth mechanical keyboard" - } - ) - print(f"Created product: {result.data}") - - # List electronics under $100 - result = await client.call_tool( - "list_products_products_get", - {"category": "Electronics", "max_price": 100} - ) - print(f"Affordable electronics: {result.data}") - -if __name__ == "__main__": - asyncio.run(demo()) -``` - -### Custom Route Mapping - -Because FastMCP's FastAPI integration is based on its [OpenAPI integration](/integrations/openapi), you can customize how endpoints are converted to MCP components in exactly the same way. For example, here we use a `RouteMap` to map all GET requests to MCP resources, and all POST/PUT/DELETE requests to MCP tools: - -```python -# Assumes the FastAPI app from above is already defined -from fastmcp import FastMCP -from fastmcp.server.providers.openapi import RouteMap, MCPType - -# Custom mapping rules -mcp = FastMCP.from_fastapi( - app=app, - route_maps=[ - # GET with path params → ResourceTemplates - RouteMap( - methods=["GET"], - pattern=r".*\{.*\}.*", - mcp_type=MCPType.RESOURCE_TEMPLATE - ), - # Other GETs → Resources - RouteMap( - methods=["GET"], - pattern=r".*", - mcp_type=MCPType.RESOURCE - ), - # POST/PUT/DELETE → Tools (default) - ], -) - -# Now: -# - GET /products → Resource -# - GET /products/{id} → ResourceTemplate -# - POST/PUT/DELETE → Tools -``` - -<Tip> -To learn more about customizing the conversion process, see the [OpenAPI Integration guide](/integrations/openapi). -</Tip> - -### Authentication and Headers - -You can configure headers and other client options via the `httpx_client_kwargs` parameter. For example, to add authentication to your FastAPI app, you can pass a `headers` dictionary to the `httpx_client_kwargs` parameter: - -```python {27-31} -# Assumes the FastAPI app from above is already defined -from fastmcp import FastMCP - -# Add authentication to your FastAPI app -from fastapi import Depends, Header -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials - -security = HTTPBearer() - -def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): - if credentials.credentials != "secret-token": - raise HTTPException(status_code=401, detail="Invalid authentication") - return credentials.credentials - -# Add a protected endpoint -@app.get("/admin/stats", dependencies=[Depends(verify_token)]) -def get_admin_stats(): - return { - "total_products": len(products_db), - "categories": list(set(p.category for p in products_db.values())) - } - -# Create MCP server with authentication headers -mcp = FastMCP.from_fastapi( - app=app, - httpx_client_kwargs={ - "headers": { - "Authorization": "Bearer secret-token", - } - } -) -``` - -## Mounting an MCP Server - -<VersionBadge version="2.3.1" /> - -In addition to generating servers, FastMCP can facilitate adding MCP servers to your existing FastAPI application. You can do this by mounting the MCP ASGI application. - -### Basic Mounting - -To mount an MCP server, you can use the `http_app` method on your FastMCP instance. This will return an ASGI application that can be mounted to your FastAPI application. - -```python {23-30} -from fastmcp import FastMCP -from fastapi import FastAPI - -# Create MCP server -mcp = FastMCP("Analytics Tools") - -@mcp.tool -def analyze_pricing(category: str) -> dict: - """Analyze pricing for a category.""" - products = [p for p in products_db.values() if p.category == category] - if not products: - return {"error": f"No products in {category}"} - - prices = [p.price for p in products] - return { - "category": category, - "avg_price": round(sum(prices) / len(prices), 2), - "min": min(prices), - "max": max(prices), - } - -# Create ASGI app from MCP server -mcp_app = mcp.http_app(path='/mcp') - -# Key: Pass lifespan to FastAPI -app = FastAPI(title="E-commerce API", lifespan=mcp_app.lifespan) - -# Mount the MCP server -app.mount("/analytics", mcp_app) - -# Now: API at /products/*, MCP at /analytics/mcp/ -``` - -## Offering an LLM-Friendly API - -A common pattern is to generate an MCP server from your FastAPI app and serve both interfaces from the same application. This provides an LLM-optimized interface alongside your regular API: - -```python -# Assumes the FastAPI app from above is already defined -from fastmcp import FastMCP -from fastapi import FastAPI - -# 1. Generate MCP server from your API -mcp = FastMCP.from_fastapi(app=app, name="E-commerce MCP") - -# 2. Create the MCP's ASGI app -mcp_app = mcp.http_app(path='/mcp') - -# 3. Create a new FastAPI app that combines both sets of routes -combined_app = FastAPI( - title="E-commerce API with MCP", - routes=[ - *mcp_app.routes, # MCP routes - *app.routes, # Original API routes - ], - lifespan=mcp_app.lifespan, -) - -# Now you have: -# - Regular API: http://localhost:8000/products -# - LLM-friendly MCP: http://localhost:8000/mcp -# Both served from the same FastAPI application! -``` - -This approach lets you maintain a single codebase while offering both traditional REST endpoints and MCP-compatible endpoints for LLM clients. - -## Key Considerations - -### Operation IDs - -FastAPI operation IDs become MCP component names. Always specify meaningful operation IDs: - -```python -# Good - explicit operation_id -@app.get("/users/{user_id}", operation_id="get_user_by_id") -def get_user(user_id: int): - return {"id": user_id} - -# Less ideal - auto-generated name -@app.get("/users/{user_id}") -def get_user(user_id: int): - return {"id": user_id} -``` - -### Lifespan Management - -When mounting MCP servers, always pass the lifespan context: - -```python -# Correct - lifespan passed, path="/" since we mount at /mcp -mcp_app = mcp.http_app(path="/") -app = FastAPI(lifespan=mcp_app.lifespan) -app.mount("/mcp", mcp_app) # MCP endpoint at /mcp - -# Incorrect - missing lifespan -app = FastAPI() -app.mount("/mcp", mcp.http_app(path="/")) # Session manager won't initialize -``` - -If you're mounting an authenticated MCP server under a path prefix, see [Mounting Authenticated Servers](/deployment/http#mounting-authenticated-servers) for important OAuth routing considerations. - -### CORS Middleware - -If your FastAPI app uses `CORSMiddleware` and you're mounting an OAuth-protected FastMCP server, avoid adding application-wide CORS middleware. FastMCP and the MCP SDK already handle CORS for OAuth routes, and layering CORS middleware can cause conflicts (such as 404 errors on `.well-known` routes or OPTIONS requests). - -If you need CORS on your own FastAPI routes, use the sub-app pattern: mount your API and FastMCP as separate apps, each with their own middleware, rather than adding top-level `CORSMiddleware` to the combined application. - -### Combining Lifespans - -If your FastAPI app already has a lifespan (for database connections, startup tasks, etc.), you can't simply replace it with the MCP lifespan. Use `combine_lifespans` to run both: - -```python -from fastapi import FastAPI -from fastmcp import FastMCP -from fastmcp.utilities.lifespan import combine_lifespans -from contextlib import asynccontextmanager - -# Your existing lifespan -@asynccontextmanager -async def app_lifespan(app: FastAPI): - print("Starting up the app...") - yield - print("Shutting down the app...") - -# Create MCP server -mcp = FastMCP("Tools") -mcp_app = mcp.http_app(path="/") - -# Combine both lifespans -app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) -app.mount("/mcp", mcp_app) # MCP endpoint at /mcp -``` - -`combine_lifespans` enters lifespans in order and exits in reverse order. - -### Performance Tips - -1. **Use in-memory transport for testing** - Pass MCP servers directly to clients -2. **Design purpose-built MCP tools** - Better than auto-converting complex APIs -3. **Keep tool parameters simple** - LLMs perform better with focused interfaces - -For more details on configuration options, see the [OpenAPI Integration guide](/integrations/openapi). \ No newline at end of file diff --git a/docs/v3/integrations/gemini-cli.mdx b/docs/v3/integrations/gemini-cli.mdx deleted file mode 100644 index 10613fb1b..000000000 --- a/docs/v3/integrations/gemini-cli.mdx +++ /dev/null @@ -1,173 +0,0 @@ ---- -title: Gemini CLI 🤝 FastMCP -sidebarTitle: Gemini CLI -description: Install and use FastMCP servers in Gemini CLI -icon: message-smile ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" -import { LocalFocusTip } from "/snippets/local-focus.mdx" - -<LocalFocusTip /> - -[Gemini CLI](https://geminicli.com/) supports MCP servers through multiple transport methods including STDIO, SSE, and HTTP, allowing you to extend Gemini's capabilities with custom tools, resources, and prompts from your FastMCP servers. - -## Requirements - -This integration uses STDIO transport to run your FastMCP server locally. For remote deployments, you can run your FastMCP server with HTTP or SSE transport and configure it directly using Gemini CLI's built-in MCP management commands. - -## Create a Server - -The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run() -``` - -## Install the Server - -### FastMCP CLI -<VersionBadge version="2.13.0" /> - -The easiest way to install a FastMCP server in Gemini CLI is using the `fastmcp install gemini-cli` command. This automatically handles the configuration, dependency management, and calls Gemini CLI's built-in MCP management system. - -```bash -fastmcp install gemini-cli server.py -``` - -The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: - -```bash -# These are equivalent if your server object is named 'mcp' -fastmcp install gemini-cli server.py -fastmcp install gemini-cli server.py:mcp - -# Use explicit object name if your server has a different name -fastmcp install gemini-cli server.py:my_custom_server -``` - -The command will automatically configure the server with Gemini CLI's `gemini mcp add` command. - -#### Dependencies - -FastMCP provides flexible dependency management options for your Gemini CLI servers: - -**Individual packages**: Use the `--with` flag to specify packages your server needs. You can use this flag multiple times: - -```bash -fastmcp install gemini-cli server.py --with pandas --with requests -``` - -**Requirements file**: If you maintain a `requirements.txt` file with all your dependencies, use `--with-requirements` to install them: - -```bash -fastmcp install gemini-cli server.py --with-requirements requirements.txt -``` - -**Editable packages**: For local packages under development, use `--with-editable` to install them in editable mode: - -```bash -fastmcp install gemini-cli server.py --with-editable ./my-local-package -``` - -Alternatively, you can use a `fastmcp.json` configuration file (recommended): - -```json fastmcp.json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - "dependencies": ["pandas", "requests"] - } -} -``` - - -#### Python Version and Project Configuration - -Control the Python environment for your server with these options: - -**Python version**: Use `--python` to specify which Python version your server requires. This ensures compatibility when your server needs specific Python features: - -```bash -fastmcp install gemini-cli server.py --python 3.11 -``` - -**Project directory**: Use `--project` to run your server within a specific project context. This tells `uv` to use the project's configuration files and virtual environment: - -```bash -fastmcp install gemini-cli server.py --project /path/to/my-project -``` - -#### Environment Variables - -If your server needs environment variables (like API keys), you must include them: - -```bash -fastmcp install gemini-cli server.py --server-name "Weather Server" \ - --env API_KEY=your-api-key \ - --env DEBUG=true -``` - -Or load them from a `.env` file: - -```bash -fastmcp install gemini-cli server.py --server-name "Weather Server" --env-file .env -``` - -<Warning> -**Gemini CLI must be installed**. The integration looks for the Gemini CLI and uses the `gemini mcp add` command to register servers. -</Warning> - -### Manual Configuration - -For more control over the configuration, you can manually use Gemini CLI's built-in MCP management commands. This gives you direct control over how your server is launched: - -```bash -# Add a server with custom configuration -gemini mcp add dice-roller uv -- run --with fastmcp fastmcp run server.py - -# Add with environment variables -gemini mcp add weather-server -e API_KEY=secret -e DEBUG=true uv -- run --with fastmcp fastmcp run server.py - -# Add with specific scope (user, or project) -gemini mcp add my-server --scope user uv -- run --with fastmcp fastmcp run server.py -``` - -You can also manually specify Python versions and project directories in your Gemini CLI commands: - -```bash -# With specific Python version -gemini mcp add ml-server uv -- run --python 3.11 --with fastmcp fastmcp run server.py - -# Within a project directory -gemini mcp add project-server uv -- run --project /path/to/project --with fastmcp fastmcp run server.py -``` - -## Using the Server - -Once your server is installed, you can start using your FastMCP server with Gemini CLI. - -Try asking Gemini something like: - -> "Roll some dice for me" - -Gemini will automatically detect your `roll_dice` tool and use it to fulfill your request. - -Gemini CLI can now access all the tools and prompts you've defined in your FastMCP server. - -If your server provides prompts, you can use them as slash commands with `/prompt_name`. diff --git a/docs/v3/integrations/gemini.mdx b/docs/v3/integrations/gemini.mdx deleted file mode 100644 index 1b17ab6ee..000000000 --- a/docs/v3/integrations/gemini.mdx +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: Gemini SDK 🤝 FastMCP -sidebarTitle: Gemini SDK -description: Connect FastMCP servers to the Google Gemini SDK -icon: message-code ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -Google's Gemini API includes built-in support for MCP servers in their Python and JavaScript SDKs, allowing you to connect directly to MCP servers and use their tools seamlessly with Gemini models. - -## Gemini Python SDK - -Google's [Gemini Python SDK](https://ai.google.dev/gemini-api/docs) can use FastMCP clients directly. - -<Note> -Google's MCP integration is currently experimental and available in the Python and JavaScript SDKs. The API automatically calls MCP tools when needed and can connect to both local and remote MCP servers. -</Note> - -<Tip> -Currently, Gemini's MCP support only accesses **tools** from MCP servers—it queries the `list_tools` endpoint and exposes those functions to the AI. Other MCP features like resources and prompts are not currently supported. -</Tip> - -### Create a Server - -First, create a FastMCP server with the tools you want to expose. For this example, we'll create a server with a single tool that rolls dice. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run() -``` - -### Call the Server - - -To use the Gemini API with MCP, you'll need to install the Google Generative AI SDK: - -```bash -pip install google-genai -``` - -You'll also need to authenticate with Google. You can do this by setting the `GEMINI_API_KEY` environment variable. Consult the Gemini SDK documentation for more information. - -```bash -export GEMINI_API_KEY="your-api-key" -``` - -Gemini's SDK interacts directly with the MCP client session. To call the server, you'll need to instantiate a FastMCP client, enter its connection context, and pass the client session to the Gemini SDK. - -```python {5, 9, 15} -from fastmcp import Client -from google import genai -import asyncio - -mcp_client = Client("server.py") -gemini_client = genai.Client() - -async def main(): - async with mcp_client: - response = await gemini_client.aio.models.generate_content( - model="gemini-2.0-flash", - contents="Roll 3 dice!", - config=genai.types.GenerateContentConfig( - temperature=0, - tools=[mcp_client.session], # Pass the FastMCP client session - ), - ) - print(response.text) - -if __name__ == "__main__": - asyncio.run(main()) -``` - -If you run this code, you'll see output like: - -```text -Okay, I rolled 3 dice and got a 5, 4, and 1. -``` - -### Remote & Authenticated Servers - -In the above example, we connected to our local server using `stdio` transport. Because we're using a FastMCP client, you can also connect to any local or remote MCP server, using any [transport](/clients/transports) or [auth](/clients/auth/oauth) method supported by FastMCP, simply by changing the client configuration. - -For example, to connect to a remote, authenticated server, you can use the following client: - -```python -from fastmcp import Client -from fastmcp.client.auth import BearerAuth - -mcp_client = Client( - "https://my-server.com/mcp/", - auth=BearerAuth("<your-token>"), -) -``` - -The rest of the code remains the same. - - diff --git a/docs/v3/integrations/github.mdx b/docs/v3/integrations/github.mdx deleted file mode 100644 index d493eb1ef..000000000 --- a/docs/v3/integrations/github.mdx +++ /dev/null @@ -1,175 +0,0 @@ ---- -title: GitHub OAuth 🤝 FastMCP -sidebarTitle: GitHub -description: Secure your FastMCP server with GitHub OAuth -icon: github ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.12.0" /> - -This guide shows you how to secure your FastMCP server using **GitHub OAuth**. Since GitHub doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge GitHub's traditional OAuth with MCP's authentication requirements. - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. A **[GitHub Account](https://github.com/)** with access to create OAuth Apps -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Create a GitHub OAuth App - -Create an OAuth App in your GitHub settings to get the credentials needed for authentication: - -<Steps> -<Step title="Navigate to OAuth Apps"> - Go to **Settings → Developer settings → OAuth Apps** in your GitHub account, or visit [github.com/settings/developers](https://github.com/settings/developers). - - Click **"New OAuth App"** to create a new application. -</Step> - -<Step title="Configure Your OAuth App"> - Fill in the application details: - - - **Application name**: Choose a name users will recognize (e.g., "My FastMCP Server") - - **Homepage URL**: Your application's homepage or documentation URL - - **Authorization callback URL**: Your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) - - <Warning> - The callback URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. For local development, GitHub allows `http://localhost` URLs. For production, you must use HTTPS. - </Warning> - - <Tip> - If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHubProvider. - </Tip> -</Step> - -<Step title="Save Your Credentials"> - After creating the app, you'll see: - - - **Client ID**: A public identifier like `Ov23liAbcDefGhiJkLmN` - - **Client Secret**: Click "Generate a new client secret" and save the value securely - - <Tip> - Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. - </Tip> -</Step> -</Steps> - -### Step 2: FastMCP Configuration - -Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OAuth quirks automatically: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider - -# The GitHubProvider handles GitHub's token format and validation -auth_provider = GitHubProvider( - client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID - client_secret="github_pat_...", # Your GitHub OAuth App Client Secret - base_url="http://localhost:8000", # Must match your OAuth App configuration - # redirect_path="/auth/callback" # Default value, customize if needed -) - -mcp = FastMCP(name="GitHub Secured App", auth=auth_provider) - -# Add a protected tool to test authentication -@mcp.tool -async def get_user_info() -> dict: - """Returns information about the authenticated GitHub user.""" - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - # The GitHubProvider stores user data in token claims - return { - "github_user": token.claims.get("login"), - "name": token.claims.get("name"), - "email": token.claims.get("email") - } -``` - -## Testing - -### Running the Server - -Start your FastMCP server with HTTP transport to enable OAuth flows: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Your server is now running and protected by GitHub OAuth authentication. - -### Testing with a Client - -Create a test client that authenticates with your GitHub-protected server: - -```python test_client.py -from fastmcp import Client -import asyncio - -async def main(): - # The client will automatically handle GitHub OAuth - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - # First-time connection will open GitHub login in your browser - print("✓ Authenticated with GitHub!") - - # Test the protected tool - result = await client.call_tool("get_user_info") - print(f"GitHub user: {result.data['github_user']}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to GitHub's authorization page -2. After you authorize the app, you'll be redirected back -3. The client receives the token and can make authenticated requests - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. -</Info> - -## Production Configuration - -<VersionBadge version="2.13.0" /> - -For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -# Production setup with encrypted persistent token storage -auth_provider = GitHubProvider( - client_id="Ov23liAbcDefGhiJkLmN", - client_secret="github_pat_...", - base_url="https://your-production-domain.com", - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production GitHub App", auth=auth_provider) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> diff --git a/docs/v3/integrations/google.mdx b/docs/v3/integrations/google.mdx deleted file mode 100644 index 17d49d12f..000000000 --- a/docs/v3/integrations/google.mdx +++ /dev/null @@ -1,189 +0,0 @@ ---- -title: Google OAuth 🤝 FastMCP -sidebarTitle: Google -description: Secure your FastMCP server with Google OAuth -icon: google ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.12.0" /> - -This guide shows you how to secure your FastMCP server using **Google OAuth**. Since Google doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Google's traditional OAuth with MCP's authentication requirements. - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. A **[Google Cloud Account](https://console.cloud.google.com/)** with access to create OAuth 2.0 Client IDs -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Create a Google OAuth 2.0 Client ID - -Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credentials needed for authentication: - -<Steps> -<Step title="Navigate to OAuth Consent Screen"> - Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials) and select your project (or create a new one). - - First, configure the OAuth consent screen by navigating to **APIs & Services → OAuth consent screen**. Choose "External" for testing or "Internal" for G Suite organizations. -</Step> - -<Step title="Create OAuth 2.0 Client ID"> - Navigate to **APIs & Services → Credentials** and click **"+ CREATE CREDENTIALS"** → **"OAuth client ID"**. - - Configure your OAuth client: - - - **Application type**: Web application - - **Name**: Choose a descriptive name (e.g., "FastMCP Server") - - **Authorized JavaScript origins**: Add your server's base URL (e.g., `http://localhost:8000`) - - **Authorized redirect URIs**: Add your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) - - <Warning> - The redirect URI must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. For local development, Google allows `http://localhost` URLs with various ports. For production, you must use HTTPS. - </Warning> - - <Tip> - If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the GoogleProvider. - </Tip> -</Step> - -<Step title="Save Your Credentials"> - After creating the client, you'll receive: - - - **Client ID**: A string ending in `.apps.googleusercontent.com` - - **Client Secret**: A string starting with `GOCSPX-` - - Download the JSON credentials or copy these values securely. - - <Tip> - Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. - </Tip> -</Step> -</Steps> - -### Step 2: FastMCP Configuration - -Create your FastMCP server using the `GoogleProvider`, which handles Google's OAuth flow automatically: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.google import GoogleProvider - -# The GoogleProvider handles Google's token format and validation -auth_provider = GoogleProvider( - client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID - client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret - base_url="http://localhost:8000", # Must match your OAuth configuration - required_scopes=[ # Request user information - "openid", - "https://www.googleapis.com/auth/userinfo.email", - ], - # redirect_path="/auth/callback" # Default value, customize if needed -) - -mcp = FastMCP(name="Google Secured App", auth=auth_provider) - -# Add a protected tool to test authentication -@mcp.tool -async def get_user_info() -> dict: - """Returns information about the authenticated Google user.""" - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - # The GoogleProvider stores user data in token claims - return { - "google_id": token.claims.get("sub"), - "email": token.claims.get("email"), - "name": token.claims.get("name"), - "picture": token.claims.get("picture"), - "locale": token.claims.get("locale") - } -``` - -## Testing - -### Running the Server - -Start your FastMCP server with HTTP transport to enable OAuth flows: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Your server is now running and protected by Google OAuth authentication. - -### Testing with a Client - -Create a test client that authenticates with your Google-protected server: - -```python test_client.py -from fastmcp import Client -import asyncio - -async def main(): - # The client will automatically handle Google OAuth - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - # First-time connection will open Google login in your browser - print("✓ Authenticated with Google!") - - # Test the protected tool - result = await client.call_tool("get_user_info") - print(f"Google user: {result['email']}") - print(f"Name: {result['name']}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to Google's authorization page -2. Sign in with your Google account and grant the requested permissions -3. After authorization, you'll be redirected back -4. The client receives the token and can make authenticated requests - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. -</Info> - -## Production Configuration - -<VersionBadge version="2.13.0" /> - -For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.google import GoogleProvider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -# Production setup with encrypted persistent token storage -auth_provider = GoogleProvider( - client_id="123456789.apps.googleusercontent.com", - client_secret="GOCSPX-abc123...", - base_url="https://your-production-domain.com", - required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"], - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production Google App", auth=auth_provider) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> \ No newline at end of file diff --git a/docs/v3/integrations/goose.mdx b/docs/v3/integrations/goose.mdx deleted file mode 100644 index fc2ff8e39..000000000 --- a/docs/v3/integrations/goose.mdx +++ /dev/null @@ -1,178 +0,0 @@ ---- -title: Goose 🤝 FastMCP -sidebarTitle: Goose -description: Install and use FastMCP servers in Goose -icon: message-smile ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" -import { LocalFocusTip } from "/snippets/local-focus.mdx" - -<LocalFocusTip /> - -[Goose](https://block.github.io/goose/) is an open-source AI agent from Block that supports MCP servers as extensions. FastMCP can install your server directly into Goose using its deeplink protocol — one command opens Goose with an install dialog ready to go. - -## Requirements - -This integration uses Goose's deeplink protocol to register your server as a STDIO extension running via `uvx`. You must have Goose installed on your system for the deeplink to open automatically. - -For remote deployments, configure your FastMCP server with HTTP transport and add it to Goose directly using `goose configure` or the config file. - -## Create a Server - -The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run() -``` - -## Install the Server - -### FastMCP CLI -<VersionBadge version="3.0.0" /> - -The easiest way to install a FastMCP server in Goose is using the `fastmcp install goose` command. This generates a `goose://` deeplink and opens it, prompting Goose to install the server. - -```bash -fastmcp install goose server.py -``` - -The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: - -```bash -# These are equivalent if your server object is named 'mcp' -fastmcp install goose server.py -fastmcp install goose server.py:mcp - -# Use explicit object name if your server has a different name -fastmcp install goose server.py:my_custom_server -``` - -Under the hood, the generated command uses `uvx` to run your server in an isolated environment. Goose requires `uvx` rather than `uv run`, so the install produces a command like: - -```bash -uvx --with pandas fastmcp run /path/to/server.py -``` - -#### Dependencies - -Use the `--with` flag to specify additional packages your server needs: - -```bash -fastmcp install goose server.py --with pandas --with requests -``` - -Alternatively, you can use a `fastmcp.json` configuration file (recommended): - -```json fastmcp.json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - "dependencies": ["pandas", "requests"] - } -} -``` - -#### Python Version - -Use `--python` to specify which Python version your server should use: - -```bash -fastmcp install goose server.py --python 3.11 -``` - -<Note> -The Goose install uses `uvx`, which does not support `--project`, `--with-requirements`, or `--with-editable`. If you need these options, use `fastmcp install mcp-json` to generate a full configuration and add it to Goose manually. -</Note> - -#### Environment Variables - -Goose's deeplink protocol does not support environment variables. If your server needs them (like API keys), you have two options: - -1. **Configure after install**: Run `goose configure` and add environment variables to the extension. -2. **Manual config**: Use `fastmcp install mcp-json` to generate the full configuration, then add it to `~/.config/goose/config.yaml` with the `envs` field. - -### Manual Configuration - -For more control, you can manually edit Goose's configuration file at `~/.config/goose/config.yaml`: - -```yaml -extensions: - dice-roller: - name: Dice Roller - cmd: uvx - args: [fastmcp, run, /path/to/server.py] - enabled: true - type: stdio - timeout: 300 -``` - -#### Dependencies - -When manually configuring, add packages using `--with` flags in the args: - -```yaml -extensions: - dice-roller: - name: Dice Roller - cmd: uvx - args: [--with, pandas, --with, requests, fastmcp, run, /path/to/server.py] - enabled: true - type: stdio - timeout: 300 -``` - -#### Environment Variables - -Environment variables can be specified in the `envs` field: - -```yaml -extensions: - weather-server: - name: Weather Server - cmd: uvx - args: [fastmcp, run, /path/to/weather_server.py] - enabled: true - envs: - API_KEY: your-api-key - DEBUG: "true" - type: stdio - timeout: 300 -``` - -You can also use `goose configure` to add extensions interactively, which prompts for environment variables. - -<Warning> -**`uvx` (from `uv`) must be installed and available in your system PATH**. Goose uses `uvx` to run Python-based extensions in isolated environments. -</Warning> - -## Using the Server - -Once your server is installed, you can start using your FastMCP server with Goose. - -Try asking Goose something like: - -> "Roll some dice for me" - -Goose will automatically detect your `roll_dice` tool and use it to fulfill your request, returning something like: - -> 🎲 Here are your dice rolls: 4, 6, 4 -> -> You rolled 3 dice with a total of 14! - -Goose can now access all the tools, resources, and prompts you've defined in your FastMCP server. diff --git a/docs/v3/integrations/huggingface.mdx b/docs/v3/integrations/huggingface.mdx deleted file mode 100644 index 55794024b..000000000 --- a/docs/v3/integrations/huggingface.mdx +++ /dev/null @@ -1,304 +0,0 @@ ---- -title: Hugging Face OAuth 🤝 FastMCP -sidebarTitle: Hugging Face -description: Secure your FastMCP server with Hugging Face OAuth -icon: hugging-face -iconType: brands ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="3.4.3" /> - -This guide shows you how to secure your FastMCP server using **Hugging Face OAuth**. -The `HuggingFaceProvider` uses FastMCP's [OAuth Proxy](/servers/auth/oauth-proxy) -pattern with Hugging Face's OAuth and OpenID Connect endpoints. It works with -manually created confidential apps, public PKCE apps, and Client ID Metadata -Documents (CIMD). - -When deploying your MCP server to Hugging Face Spaces, Spaces can create and -manage the OAuth app for you. - -## Configuration - -### Prerequisites - -Before you begin, you will need: - -1. A **[Hugging Face account](https://huggingface.co/join)** with access to create OAuth apps -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Create a Hugging Face OAuth app - -Create an OAuth app from your [Hugging Face application settings](https://huggingface.co/settings/applications/new). -For details, see Hugging Face's [OAuth documentation](https://huggingface.co/docs/hub/oauth). - -<Steps> -<Step title="Create the OAuth app"> - Go to your [Hugging Face application settings](https://huggingface.co/settings/applications/new) - and create a new OAuth application. - - Choose a name users will recognize, then configure the redirect URL for - your FastMCP server: - - - Development: `http://localhost:8000/auth/callback` - - Production: `https://your-domain.com/auth/callback` - - <Warning> - The redirect URL must match exactly. The default path is `/auth/callback`, - but you can customize it using the `redirect_path` parameter. For - production, use HTTPS. - </Warning> -</Step> - -<Step title="Save your credentials"> - After creating the app, save: - - - **Client ID**: The public identifier for your Hugging Face OAuth app - - **Client Secret**: The app secret, if you created a confidential app - - <Tip> - Store the client secret securely. Never commit it to version control. Use - environment variables or a secrets manager in production. - </Tip> -</Step> -</Steps> - -### Step 2: Configure FastMCP - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.huggingface import HuggingFaceProvider - -# The HuggingFaceProvider handles Hugging Face's opaque OAuth access tokens -# and stores user data in token claims. -auth_provider = HuggingFaceProvider( - client_id="your-huggingface-client-id", # Your Hugging Face OAuth app client ID - client_secret="your-huggingface-client-secret", # Your Hugging Face OAuth app client secret - base_url="http://localhost:8000", # Must match your OAuth configuration - required_scopes=["openid", "profile"], # Default value - # redirect_path="/auth/callback" # Default value, customize if needed -) - -mcp = FastMCP(name="Hugging Face Secured App", auth=auth_provider) - - -# Add a protected tool to test authentication -@mcp.tool -async def get_user_info() -> dict: - """Returns information about the authenticated Hugging Face user.""" - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - return { - "subject": token.claims.get("sub"), - "username": token.claims.get("preferred_username"), - "profile": token.claims.get("profile"), - } -``` - -## Public OAuth apps, DCR, and CIMD - -Hugging Face supports public OAuth apps (no client secret). For public apps, -omit `client_secret` and provide a `jwt_signing_key` so FastMCP can sign its -own proxy tokens: - -```python -auth_provider = HuggingFaceProvider( - client_id="your-public-huggingface-client-id", - base_url="http://localhost:8000", - jwt_signing_key="replace-with-a-secure-secret", -) -``` - -MCP clients can use Dynamic Client Registration with your FastMCP server. The -`HuggingFaceProvider` inherits FastMCP's OAuth Proxy behavior, which handles -client registration locally and forwards authorization to Hugging Face using -your configured Hugging Face OAuth app. In other words, MCP clients register -with FastMCP, while FastMCP uses your Hugging Face `client_id` and optional -`client_secret` for the upstream OAuth flow. - -You can also use a Client ID Metadata Document URL as the `client_id` when your -client metadata is hosted at a stable HTTPS URL: - -```python -auth_provider = HuggingFaceProvider( - client_id="https://your-client.example/.well-known/oauth-cimd", - base_url="http://localhost:8000", - jwt_signing_key="replace-with-a-secure-secret", -) -``` - -## Testing - -### Running the Server - -Start your server with HTTP transport: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Your server is now running and protected by Hugging Face OAuth authentication. - -### Testing with a Client - -Create a test client that authenticates with your Hugging Face-protected server: - -```python test_client.py -import asyncio -from fastmcp import Client - - -async def main(): - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - result = await client.call_tool("get_user_info") - print(result) - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: - -1. Your browser will open to Hugging Face's authorization page -2. Sign in with your Hugging Face account and grant the requested permissions -3. After authorization, you'll be redirected back -4. The client receives the token and can make authenticated requests - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for -subsequent runs unless the token expires or you explicitly clear the cache. -</Info> - -## Hugging Face Spaces - -When deploying to [Hugging Face Spaces](https://huggingface.co/docs/hub/spaces-oauth), -Spaces can create and manage the OAuth app for you. Add OAuth metadata to your -Space README: - -```yaml ---- -title: FastMCP Hugging Face OAuth -sdk: docker -hf_oauth: true -hf_oauth_expiration_minutes: 480 -hf_oauth_scopes: - - email - - inference-api ---- -``` - -Spaces provide `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_SCOPES`, -`OPENID_PROVIDER_URL`, and `SPACE_HOST` environment variables: - -```python -import os - -from fastmcp import FastMCP -from fastmcp.server.auth.providers.huggingface import HuggingFaceProvider -from fastmcp.utilities.auth import parse_scopes - -base_url = f"https://{os.environ['SPACE_HOST']}" - -auth_provider = HuggingFaceProvider( - client_id=os.environ["OAUTH_CLIENT_ID"], - client_secret=os.environ["OAUTH_CLIENT_SECRET"], - base_url=base_url, - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - required_scopes=parse_scopes(os.environ.get("OAUTH_SCOPES")) or ["openid", "profile"], -) - -mcp = FastMCP(name="Hugging Face Space App", auth=auth_provider) -``` - -Set `JWT_SIGNING_KEY` as a Space secret. - -## Hugging Face scopes - -The default scopes are `openid` and `profile`. Add more scopes when your tools -need Hub capabilities: - -| Scope | Description | -|-------|-------------| -| `email` | Access the user's email address | -| `read-billing` | Know whether the user has a payment method set up | -| `read-repos` | Read the user's personal repositories | -| `gated-repos` | Read public gated repositories the user can access | -| `contribute-repos` | Create repositories and access app-created repositories | -| `write-repos` | Read and write the user's personal repositories | -| `manage-repos` | Full repository access, including creation and deletion | -| `read-collections` | Read the user's personal collections | -| `write-collections` | Read and write the user's personal collections, including collection creation and deletion | -| `inference-api` | Use Hugging Face Inference Providers as the user | -| `jobs` | Run Hugging Face Jobs | -| `webhooks` | Manage webhooks | -| `write-discussions` | Open discussions and pull requests, and interact with discussions | - -```python -auth_provider = HuggingFaceProvider( - client_id="your-huggingface-client-id", - client_secret="your-huggingface-client-secret", - base_url="https://your-domain.com", - required_scopes=["openid", "profile", "inference-api", "jobs"], -) -``` - -For organization resources, use Hugging Face's normal OAuth organization grant -flow. If you need a specific organization, pass Hugging Face's `orgIds` -authorization parameter. The value is the organization ID from the -`organizations.sub` field in the Hugging Face userinfo response: - -```python -auth_provider = HuggingFaceProvider( - client_id="your-huggingface-client-id", - client_secret="your-huggingface-client-secret", - base_url="https://your-domain.com", - extra_authorize_params={"orgIds": "your-org-id"}, -) -``` - -## Production Configuration - -For production deployments with persistent token management across server -restarts, configure `jwt_signing_key` and `client_storage`: - -```python server.py -import os -from cryptography.fernet import Fernet -from fastmcp import FastMCP -from fastmcp.server.auth.providers.huggingface import HuggingFaceProvider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper - -# Production setup with encrypted persistent token storage -auth_provider = HuggingFaceProvider( - client_id="your-huggingface-client-id", - client_secret=os.environ["HUGGINGFACE_CLIENT_SECRET"], - base_url="https://your-production-domain.com", - required_scopes=["openid", "profile", "email"], - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production Hugging Face App", auth=auth_provider) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure -tokens and client registrations survive server restarts. **Wrap your storage in -`FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without -it, tokens are stored in plaintext. Store secrets in environment variables and -use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> diff --git a/docs/v3/integrations/images/authkit/enable_dcr.png b/docs/v3/integrations/images/authkit/enable_dcr.png deleted file mode 100644 index e5942f60e..000000000 Binary files a/docs/v3/integrations/images/authkit/enable_dcr.png and /dev/null differ diff --git a/docs/v3/integrations/images/oci/ociaddapplication.png b/docs/v3/integrations/images/oci/ociaddapplication.png deleted file mode 100644 index 690f8d391..000000000 Binary files a/docs/v3/integrations/images/oci/ociaddapplication.png and /dev/null differ diff --git a/docs/v3/integrations/images/oci/ocieditdomainsettings.png b/docs/v3/integrations/images/oci/ocieditdomainsettings.png deleted file mode 100644 index 08812ba9b..000000000 Binary files a/docs/v3/integrations/images/oci/ocieditdomainsettings.png and /dev/null differ diff --git a/docs/v3/integrations/images/oci/ocieditdomainsettingsbutton.png b/docs/v3/integrations/images/oci/ocieditdomainsettingsbutton.png deleted file mode 100644 index 3954dab07..000000000 Binary files a/docs/v3/integrations/images/oci/ocieditdomainsettingsbutton.png and /dev/null differ diff --git a/docs/v3/integrations/images/oci/ocioauthconfiguration.png b/docs/v3/integrations/images/oci/ocioauthconfiguration.png deleted file mode 100644 index f00782154..000000000 Binary files a/docs/v3/integrations/images/oci/ocioauthconfiguration.png and /dev/null differ diff --git a/docs/v3/integrations/images/permit/abac_condition_example.png b/docs/v3/integrations/images/permit/abac_condition_example.png deleted file mode 100644 index a5592abca..000000000 Binary files a/docs/v3/integrations/images/permit/abac_condition_example.png and /dev/null differ diff --git a/docs/v3/integrations/images/permit/abac_policy_example.png b/docs/v3/integrations/images/permit/abac_policy_example.png deleted file mode 100644 index bd4b5cf33..000000000 Binary files a/docs/v3/integrations/images/permit/abac_policy_example.png and /dev/null differ diff --git a/docs/v3/integrations/images/permit/policy_mapping.png b/docs/v3/integrations/images/permit/policy_mapping.png deleted file mode 100644 index d100b1eab..000000000 Binary files a/docs/v3/integrations/images/permit/policy_mapping.png and /dev/null differ diff --git a/docs/v3/integrations/keycloak.mdx b/docs/v3/integrations/keycloak.mdx deleted file mode 100644 index 22d61f132..000000000 --- a/docs/v3/integrations/keycloak.mdx +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: Keycloak OAuth 🤝 FastMCP -sidebarTitle: Keycloak -description: Secure your FastMCP server with Keycloak OAuth -icon: shield-check -tag: NEW ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="3.2.4" /> - -This guide shows you how to secure your FastMCP server using **Keycloak OAuth**. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern with Dynamic Client Registration (DCR), where Keycloak handles user login and your FastMCP server validates the tokens. - -<Note> -**Keycloak 26.6.0 or later is required.** Earlier versions had a DCR incompatibility with MCP clients ([PR #45309](https://github.com/keycloak/keycloak/pull/45309)) that is fixed in 26.6.0. -</Note> - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. A running **[Keycloak](https://keycloak.org/)** instance (e.g., `http://localhost:8080`) -2. A Keycloak realm with **Dynamic Client Registration** enabled and a trusted host policy that allows your server URL (e.g., `http://localhost:8000/*`) -3. Your FastMCP server's public URL (e.g., `http://localhost:8000`) - -### FastMCP Configuration - -Create your FastMCP server and use `KeycloakAuthProvider` to handle OAuth: - -```python server.py -import os - -from fastmcp import FastMCP -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider -from fastmcp.server.dependencies import get_access_token - -auth = KeycloakAuthProvider( - realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/myrealm", - base_url="http://localhost:8000", - # audience="http://localhost:8000", # Recommended for production -) - -mcp = FastMCP("Keycloak Example Server", auth=auth) - - -@mcp.tool -async def get_access_token_claims() -> dict: - """Get the authenticated user's access token claims.""" - token = get_access_token() - return { - "sub": token.claims.get("sub"), - "scope": token.claims.get("scope"), - "azp": token.claims.get("azp"), - } -``` - -<Warning> -**Production security**: Always configure the `audience` parameter in production. Without it, your server accepts tokens issued for any audience. Configure Keycloak audience mappers and set `audience` to your server's base URL to ensure tokens are specifically intended for your server. -</Warning> - -## Local Development - -Local infrastructure tooling is deliberately kept out of the FastMCP core library to keep auth integrations slim and the associated maintenance burden as low as possible. That said, Keycloak is a popular identity provider for local development and testing, so a dedicated FastMCP-compatible setup blueprint lives in the companion project [**fastmcp-keycloak-local**](https://github.com/stephaneberle9/fastmcp-keycloak-local). - -It provides everything needed to develop and test FastMCP servers with Keycloak OAuth locally: a Docker-based Keycloak setup with a pre-configured `fastmcp` realm (Dynamic Client Registration enabled, test user included), cross-platform start scripts, and integration guides for the MCP Inspector, Claude Desktop, and Claude Code CLI. - -## Testing - -### Running the Server - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -### Testing with a Client - -```python client.py -import asyncio -from fastmcp import Client - -async def main(): - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - print("✓ Authenticated with Keycloak!") - result = await client.call_tool("get_access_token_claims") - print(f"sub: {result.data.get('sub', 'N/A')}") - -asyncio.run(main()) -``` - -On first run, your browser will open to Keycloak's authorization page. After login, the client receives a token and caches it for subsequent runs. - -## Features - -### JWT Token Validation - -- **Signature Verification**: Validates tokens against Keycloak's JWKS endpoint -- **Expiration Checking**: Automatically rejects expired tokens -- **Issuer Validation**: Ensures tokens come from your specific Keycloak realm -- **Scope Enforcement**: Verifies required OAuth scopes are present -- **Audience Validation**: Optional validation that tokens target your server (configure `audience`) - -### User Claims - -Access user information from Keycloak JWT tokens: - -```python -from fastmcp.server.dependencies import get_access_token - -@mcp.tool -async def admin_only_tool() -> str: - """A tool only available to admin users.""" - token = get_access_token() - roles = token.claims.get("realm_access", {}).get("roles", []) - if "admin" not in roles: - raise ValueError("This tool requires admin access") - return "Admin access granted!" -``` - -## Advanced Configuration - -### Custom Token Verifier - -```python -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider - -custom_verifier = JWTVerifier( - jwks_uri="http://localhost:8080/realms/myrealm/protocol/openid-connect/certs", - issuer="http://localhost:8080/realms/myrealm", - audience="my-resource-server", - required_scopes=["api:read", "api:write"], -) - -auth = KeycloakAuthProvider( - realm_url="http://localhost:8080/realms/myrealm", - base_url="http://localhost:8000", - token_verifier=custom_verifier, -) -``` diff --git a/docs/v3/integrations/mcp-json-configuration.mdx b/docs/v3/integrations/mcp-json-configuration.mdx deleted file mode 100644 index fec8ffc01..000000000 --- a/docs/v3/integrations/mcp-json-configuration.mdx +++ /dev/null @@ -1,514 +0,0 @@ ---- -title: MCP JSON Configuration 🤝 FastMCP -sidebarTitle: MCP.json -description: Generate standard MCP configuration files for any compatible client -icon: brackets-curly ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.10.3" /> - -FastMCP can generate standard MCP JSON configuration files that work with any MCP-compatible client including Claude Desktop, VS Code, Cursor, and other applications that support the Model Context Protocol. - -## MCP JSON Configuration Standard - -The MCP JSON configuration format is an **emergent standard** that has developed across the MCP ecosystem. This format defines how MCP clients should configure and launch MCP servers, providing a consistent way to specify server commands, arguments, and environment variables. - -### Configuration Structure - -The standard uses a `mcpServers` object where each key represents a server name and the value contains the server's configuration: - -```json -{ - "mcpServers": { - "server-name": { - "command": "executable", - "args": ["arg1", "arg2"], - "env": { - "VAR": "value" - } - } - } -} -``` - -### Server Configuration Fields - -#### `command` (required) -The executable command to run the MCP server. This should be an absolute path or a command available in the system PATH. - -```json -{ - "command": "python" -} -``` - -#### `args` (optional) -An array of command-line arguments passed to the server executable. Arguments are passed in order. - -```json -{ - "args": ["server.py", "--verbose", "--port", "8080"] -} -``` - -#### `env` (optional) -An object containing environment variables to set when launching the server. All values must be strings. - -```json -{ - "env": { - "API_KEY": "secret-key", - "DEBUG": "true", - "PORT": "8080" - } -} -``` - -### Client Adoption - -This format is widely adopted across the MCP ecosystem: - -- **Claude Desktop**: Uses `~/.claude/claude_desktop_config.json` -- **Cursor**: Uses `~/.cursor/mcp.json` -- **VS Code**: Uses workspace `.vscode/mcp.json` -- **Other clients**: Many MCP-compatible applications follow this standard - -## Overview - -<Note> -**For the best experience, use FastMCP's first-class integrations:** [`fastmcp install claude-code`](/integrations/claude-code), [`fastmcp install claude-desktop`](/integrations/claude-desktop), or [`fastmcp install cursor`](/integrations/cursor). Use MCP JSON generation for advanced use cases and unsupported clients. -</Note> - -The `fastmcp install mcp-json` command generates configuration in the standard `mcpServers` format used across the MCP ecosystem. This is useful when: - -- **Working with unsupported clients** - Any MCP client not directly integrated with FastMCP -- **CI/CD environments** - Automated configuration generation for deployments -- **Configuration sharing** - Easy distribution of server setups to team members -- **Custom tooling** - Integration with your own MCP management tools -- **Manual setup** - When you prefer to manually configure your MCP client - -## Basic Usage - -Generate configuration and output to stdout (useful for piping): - -```bash -fastmcp install mcp-json server.py -``` - -This outputs the server configuration JSON with the server name as the root key: - -```json -{ - "My Server": { - "command": "uv", - "args": [ - "run", - "--with", - "fastmcp", - "fastmcp", - "run", - "/absolute/path/to/server.py" - ] - } -} -``` - -To use this in a client configuration file, add it to the `mcpServers` object in your client's configuration: - -```json -{ - "mcpServers": { - "My Server": { - "command": "uv", - "args": [ - "run", - "--with", - "fastmcp", - "fastmcp", - "run", - "/absolute/path/to/server.py" - ] - } - } -} -``` - -<Note> -When using `--python`, `--project`, or `--with-requirements`, the generated configuration will include these options in the `uv run` command, ensuring your server runs with the correct Python version and dependencies. -</Note> - -<Note> -Different MCP clients may have specific configuration requirements or formatting needs. Always consult your client's documentation to ensure proper integration. -</Note> - -## Configuration Options - -### Server Naming - -```bash -# Use server's built-in name (from FastMCP constructor) -fastmcp install mcp-json server.py - -# Override with custom name -fastmcp install mcp-json server.py --name "Custom Server Name" -``` - -### Dependencies - -Add Python packages your server needs: - -```bash -# Single package -fastmcp install mcp-json server.py --with pandas - -# Multiple packages -fastmcp install mcp-json server.py --with pandas --with requests --with httpx - -# Editable local package -fastmcp install mcp-json server.py --with-editable ./my-package - -# From requirements file -fastmcp install mcp-json server.py --with-requirements requirements.txt -``` - -You can also use a `fastmcp.json` configuration file (recommended): - -```json fastmcp.json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py", - "entrypoint": "mcp" - }, - "environment": { - "dependencies": ["pandas", "matplotlib", "seaborn"] - } -} -``` - -Then simply install with: -```bash -fastmcp install mcp-json fastmcp.json -``` - - -### Environment Variables - -```bash -# Individual environment variables -fastmcp install mcp-json server.py \ - --env API_KEY=your-secret-key \ - --env DEBUG=true - -# Load from .env file -fastmcp install mcp-json server.py --env-file .env -``` - -### Python Version and Project Directory - -Specify Python version or run within a specific project: - -```bash -# Use specific Python version -fastmcp install mcp-json server.py --python 3.11 - -# Run within a project directory -fastmcp install mcp-json server.py --project /path/to/project -``` - -### Server Object Selection - -Use the same `file.py:object` notation as other FastMCP commands: - -```bash -# Auto-detects server object (looks for 'mcp', 'server', or 'app') -fastmcp install mcp-json server.py - -# Explicit server object -fastmcp install mcp-json server.py:my_custom_server -``` - -## Clipboard Integration - -Copy configuration directly to your clipboard for easy pasting: - -```bash -fastmcp install mcp-json server.py --copy -``` - -<Note> -The `--copy` flag requires the `pyperclip` Python package. If not installed, you'll see an error message with installation instructions. -</Note> - -## Usage Examples - -### Basic Server - -```bash -fastmcp install mcp-json dice_server.py -``` - -Output: -```json -{ - "Dice Server": { - "command": "uv", - "args": [ - "run", - "--with", - "fastmcp", - "fastmcp", - "run", - "/home/user/dice_server.py" - ] - } -} -``` - -### Production Server with Dependencies - -```bash -fastmcp install mcp-json api_server.py \ - --name "Production API Server" \ - --with requests \ - --with python-dotenv \ - --env API_BASE_URL=https://api.example.com \ - --env TIMEOUT=30 -``` - -### Advanced Configuration - -```bash -fastmcp install mcp-json ml_server.py \ - --name "ML Analysis Server" \ - --python 3.11 \ - --with-requirements requirements.txt \ - --project /home/user/ml-project \ - --env GPU_DEVICE=0 -``` - -Output: -```json -{ - "Production API Server": { - "command": "uv", - "args": [ - "run", - "--with", - "fastmcp", - "--with", - "python-dotenv", - "--with", - "requests", - "fastmcp", - "run", - "/home/user/api_server.py" - ], - "env": { - "API_BASE_URL": "https://api.example.com", - "TIMEOUT": "30" - } - } -} -``` - -The advanced configuration example generates: -```json -{ - "ML Analysis Server": { - "command": "uv", - "args": [ - "run", - "--python", - "3.11", - "--project", - "/home/user/ml-project", - "--with", - "fastmcp", - "--with-requirements", - "requirements.txt", - "fastmcp", - "run", - "/home/user/ml_server.py" - ], - "env": { - "GPU_DEVICE": "0" - } - } -} -``` - -### Pipeline Usage - -Save configuration to file: - -```bash -fastmcp install mcp-json server.py > mcp-config.json -``` - -Use in shell scripts: - -```bash -#!/bin/bash -CONFIG=$(fastmcp install mcp-json server.py --name "CI Server") -echo "$CONFIG" | jq '."CI Server".command' -# Output: "uv" -``` - -### UV-Managed Project Dependencies - -For servers that live inside a uv-managed project (with `pyproject.toml`), use the `--project` flag to run within that project's environment: - -```bash -fastmcp install mcp-json server.py --project . -``` - -Output: -```json -{ - "My Server": { - "command": "uv", - "args": [ - "run", - "--project", - "/absolute/path/to/project", - "--with", - "fastmcp", - "fastmcp", - "run", - "/absolute/path/to/project/server.py" - ] - } -} -``` - -You can also use `fastmcp.json` with a local project: - -```json fastmcp.json -{ - "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "source": { - "path": "server.py" - }, - "environment": { - "project": "." - } -} -``` - -If your server needs additional packages beyond those in `pyproject.toml`, add them via the `dependencies` array or `--with`. - -### Published Packages with `uvx` - -If your team publishes MCP servers as pip packages, you can configure clients to run them with `uvx` directly instead of `uv run`. For example, if your package is called `my-mcp-server` and provides a CLI entry point of the same name: - -```json -{ - "mcpServers": { - "My Server": { - "command": "uvx", - "args": ["my-mcp-server"] - } - } -} -``` - -If the package name differs from the CLI command (e.g., package `weather-mcp` with command `weather-server`): - -```json -{ - "mcpServers": { - "Weather": { - "command": "uvx", - "args": ["--from", "weather-mcp", "weather-server"] - } - } -} -``` - -You can also pin Python versions or add extra dependencies: - -```json -{ - "mcpServers": { - "My Server": { - "command": "uvx", - "args": [ - "--python", "3.12", - "--with", "requests", - "my-mcp-server" - ] - } - } -} -``` - -<Note> -`fastmcp install mcp-json` generates `uv run` configurations for local development. For published packages, you'll typically write the `uvx` configuration manually or generate it through your own packaging workflow. -</Note> - -## Integration with MCP Clients - -The generated configuration works with any MCP-compatible application: - -### Claude Desktop -<Note> -**Prefer [`fastmcp install claude-desktop`](/integrations/claude-desktop)** for automatic installation. Use MCP JSON for advanced configuration needs. -</Note> -Copy the `mcpServers` object into `~/.claude/claude_desktop_config.json` - -### Cursor -<Note> -**Prefer [`fastmcp install cursor`](/integrations/cursor)** for automatic installation. Use MCP JSON for advanced configuration needs. -</Note> -Add to `~/.cursor/mcp.json` - -### VS Code -Add to your workspace's `.vscode/mcp.json` file - -### Custom Applications -Use the JSON configuration with any application that supports the MCP protocol - -## Configuration Format - -The generated configuration outputs a server object with the server name as the root key: - -```json -{ - "<server-name>": { - "command": "<executable>", - "args": ["<arg1>", "<arg2>", "..."], - "env": { - "<ENV_VAR>": "<value>" - } - } -} -``` - -To use this in an MCP client, add it to the client's `mcpServers` configuration object. - -**Fields:** -- `command`: The executable to run (always `uv` for FastMCP servers) -- `args`: Command-line arguments including dependencies and server path -- `env`: Environment variables (only included if specified) - -<Warning> -**All file paths in the generated configuration are absolute paths**. This ensures the configuration works regardless of the working directory when the MCP client starts the server. -</Warning> - -## Requirements - -- **uv**: Must be installed and available in your system PATH -- **pyperclip** (optional): Required only for `--copy` functionality - -Install uv if not already available: - -```bash -# macOS -brew install uv - -# Linux/Windows -curl -LsSf https://astral.sh/uv/install.sh | sh -``` diff --git a/docs/v3/integrations/oci.mdx b/docs/v3/integrations/oci.mdx deleted file mode 100644 index 02fa36dae..000000000 --- a/docs/v3/integrations/oci.mdx +++ /dev/null @@ -1,248 +0,0 @@ ---- -title: OCI IAM OAuth 🤝 FastMCP -sidebarTitle: Oracle -description: Secure your FastMCP server with OCI IAM OAuth -icon: shield-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.13.0" /> - -This guide shows you how to secure your FastMCP server using **OCI IAM OAuth**. Since OCI IAM doesn't support Dynamic Client Registration, this integration uses the [**OIDC Proxy**](/servers/auth/oidc-proxy) pattern to bridge OCI's traditional OAuth with MCP's authentication requirements. - -## Configuration - -### Prerequisites - -1. An OCI cloud Account with access to create an Integrated Application in an Identity Domain. -2. Your FastMCP server's URL (For dev environments, it is http://localhost:8000. For PROD environments, it could be https://mcp.yourdomain.com) - -### Step 1: Make sure client access is enabled for JWK's URL - -<Steps> -<Step title="Navigate to OCI IAM Domain Settings"> - - Login to OCI console (https://cloud.oracle.com for OCI commercial cloud). - From "Identity & Security" menu, open Domains page. - On the Domains list page, select the domain that you are using for MCP Authentication. - Open Settings tab. - Click on "Edit Domain Settings" button. - - <Frame> - <img src="/integrations/images/oci/ocieditdomainsettingsbutton.png" alt="OCI console showing the Edit Domain Settings button in the IAM Domain settings page" /> - </Frame> -</Step> - -<Step title="Update Domain Setting"> - - Enable "Configure client access" checkbox as shown in the screenshot. - - <Frame> - <img src="/integrations/images/oci/ocieditdomainsettings.png" alt="OCI IAM Domain Settings" /> - </Frame> -</Step> -</Steps> - -### Step 2: Create OAuth client for MCP server authentication - -Follow the Steps as mentioned below to create an OAuth client. - -<Steps> -<Step title="Navigate to OCI IAM Integrated Applications"> - - Login to OCI console (https://cloud.oracle.com for OCI commercial cloud). - From "Identity & Security" menu, open Domains page. - On the Domains list page, select the domain in which you want to create MCP server OAuth client. If you need help finding the list page for the domain, see [Listing Identity Domains.](https://docs.oracle.com/en-us/iaas/Content/Identity/domains/to-view-identity-domains.htm#view-identity-domains). - On the details page, select Integrated applications. A list of applications in the domain is displayed. -</Step> - -<Step title="Add an Integrated Application"> - - Select Add application. - In the Add application window, select Confidential Application. - Select Launch workflow. - In the Add application details page, Enter name and description as shown below. - - <Frame> - <img src="/integrations/images/oci/ociaddapplication.png" alt="Adding a Confidential Integrated Application in OCI IAM Domain" /> - </Frame> -</Step> - -<Step title="Update OAuth Configuration for an Integrated Application"> - - Once the Integrated Application is created, Click on "OAuth configuration" tab. - Click on "Edit OAuth configuration" button. - Configure the application as OAuth client by selecting "Configure this application as a client now" radio button. - Select "Authorization code" grant type. If you are planning to use the same OAuth client application for token exchange, select "Client credentials" grant type as well. In the sample, we will use the same client. - For Authorization grant type, select redirect URL. In most cases, this will be the MCP server URL followed by "/oauth/callback". - - <Frame> - <img src="/integrations/images/oci/ocioauthconfiguration.png" alt="OAuth Configuration for an Integrated Application in OCI IAM Domain" /> - </Frame> -</Step> - -<Step title="Activate the Integrated Application"> - - Click on "Submit" button to update OAuth configuration for the client application. - **Note: You don't need to do any special configuration to support PKCE for the OAuth client.** - Make sure to Activate the client application. - Note down client ID and client secret for the application. You'll use these values when configuring the OCIProvider in your code. -</Step> -</Steps> - -This is all you need to implement MCP server authentication against OCI IAM. However, you may want to use an authenticated user token to invoke OCI control plane APIs and propagate identity to the OCI control plane instead of using a service user account. In that case, you need to implement token exchange. - -### Step 3: Token Exchange Setup (Only if MCP server needs to talk to OCI Control Plane) - -Token exchange helps you exchange a logged-in user's OCI IAM token for an OCI control plane session token, also known as UPST (User Principal Session Token). To learn more about token exchange, refer to my [Workload Identity Federation Blog](https://www.ateam-oracle.com/post/workload-identity-federation) - -For token exchange, we need to configure Identity propagation trust. The blog above discusses setting up the trust using REST APIs. However, you can also use OCI CLI. Before using the CLI command below, ensure that you have created a token exchange OAuth client. In most cases, you can use the same OAuth client that you created above. Replace `<IAM_GUID>` and `<CLIENT_ID>` in the CLI command below with your actual values. - -```bash -oci identity-domains identity-propagation-trust create \ ---schemas '["urn:ietf:params:scim:schemas:oracle:idcs:IdentityPropagationTrust"]' \ ---public-key-endpoint "https://<IAM_GUID>.identity.oraclecloud.com/admin/v1/SigningCert/jwk" \ ---name "For Token Exchange" --type "JWT" \ ---issuer "https://identity.oraclecloud.com/" --active true \ ---endpoint "https://<IAM_GUID>.identity.oraclecloud.com" \ ---subject-claim-name "sub" --allow-impersonation false \ ---subject-mapping-attribute "username" \ ---subject-type "User" --client-claim-name "iss" \ ---client-claim-values '["https://identity.oraclecloud.com/"]' \ ---oauth-clients '["<CLIENT_ID>"]' -``` - -To exchange access token for OCI token and create a signer object, you need to add below code in MCP server. You can then use the signer object to create any OCI control plane client. - -```python - -from fastmcp.server.dependencies import get_access_token -from fastmcp.utilities.logging import get_logger -from oci.auth.signers import TokenExchangeSigner -import os - -logger = get_logger(__name__) - -# Load configuration from environment -OCI_IAM_GUID = os.environ.get("OCI_IAM_GUID") -OCI_CLIENT_ID = os.environ.get("OCI_CLIENT_ID") -OCI_CLIENT_SECRET = os.environ.get("OCI_CLIENT_SECRET") - -_global_token_cache = {} #In memory cache for OCI session token signer - -def get_oci_signer() -> TokenExchangeSigner: - - authntoken = get_access_token() - tokenID = authntoken.claims.get("jti") - token = authntoken.token - - #Check if the signer exists for the token ID in memory cache - cached_signer = _global_token_cache.get(tokenID) - logger.debug(f"Global cached signer: {cached_signer}") - if cached_signer: - logger.debug(f"Using globally cached signer for token ID: {tokenID}") - return cached_signer - - #If the signer is not yet created for the token then create new OCI signer object - logger.debug(f"Creating new signer for token ID: {tokenID}") - signer = TokenExchangeSigner( - jwt_or_func=token, - oci_domain_id=OCI_IAM_GUID.split(".")[0] if OCI_IAM_GUID else "", - client_id=OCI_CLIENT_ID, - client_secret=OCI_CLIENT_SECRET, - ) - logger.debug(f"Signer {signer} created for token ID: {tokenID}") - - #Cache the signer object in memory cache - _global_token_cache[tokenID] = signer - logger.debug(f"Signer cached for token ID: {tokenID}") - - return signer -``` - -## Running MCP server - -Once the setup is complete, to run the MCP server, run the below command. -```bash -fastmcp run server.py:mcp --transport http --port 8000 -``` - -To run MCP client, run the below command. -```bash -python3 client.py -``` - -MCP Client sample is as below. -```python client.py -from fastmcp import Client -import asyncio - -async def main(): - # The client will automatically handle OCI OAuth flows - async with Client("http://localhost:8000/mcp/", auth="oauth") as client: - # First-time connection will open OCI login in your browser - print("✓ Authenticated with OCI IAM") - - tools = await client.list_tools() - print(f"🔧 Available tools ({len(tools)}):") - for tool in tools: - print(f" - {tool.name}: {tool.description}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to OCI IAM's login page -2. Sign in with your OCI account and grant the requested consent -3. After authorization, you'll be redirected back to the redirect path -4. The client receives the token and can make authenticated requests - -## Production Configuration - -<VersionBadge version="2.13.0" /> - -For production deployments with persistent token management across server restarts, configure `jwt_signing_key`, and `client_storage`: - -```python server.py - -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.oci import OCIProvider - -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -# Load configuration from environment -# Production setup with encrypted persistent token storage -auth_provider = OCIProvider( - config_url=os.environ.get("OCI_CONFIG_URL"), - client_id=os.environ.get("OCI_CLIENT_ID"), - client_secret=os.environ.get("OCI_CLIENT_SECRET"), - base_url=os.environ.get("BASE_URL", "https://your-production-domain.com"), - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production OCI App", auth=auth_provider) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at Rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. -</Info> \ No newline at end of file diff --git a/docs/v3/integrations/openai.mdx b/docs/v3/integrations/openai.mdx deleted file mode 100644 index 94ca82b40..000000000 --- a/docs/v3/integrations/openai.mdx +++ /dev/null @@ -1,227 +0,0 @@ ---- -title: OpenAI API 🤝 FastMCP -sidebarTitle: OpenAI API -description: Connect FastMCP servers to the OpenAI API -icon: message-code ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - - -## Responses API - -OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) supports [MCP servers](https://platform.openai.com/docs/guides/tools-remote-mcp) as remote tool sources, allowing you to extend AI capabilities with custom functions. - -<Note> -The Responses API is a distinct API from OpenAI's Completions API or Assistants API. At this time, only the Responses API supports MCP. -</Note> - -<Tip> -Currently, the Responses API only accesses **tools** from MCP servers—it queries the `list_tools` endpoint and exposes those functions to the AI agent. Other MCP features like resources and prompts are not currently supported. -</Tip> - - -### Create a Server - -First, create a FastMCP server with the tools you want to expose. For this example, we'll create a server with a single tool that rolls dice. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -### Deploy the Server - -Your server must be deployed to a public URL in order for OpenAI to access it. - -For development, you can use tools like `ngrok` to temporarily expose a locally-running server to the internet. We'll do that for this example (you may need to install `ngrok` and create a free account), but you can use any other method to deploy your server. - -Assuming you saved the above code as `server.py`, you can run the following two commands in two separate terminals to deploy your server and expose it to the internet: - -<CodeGroup> -```bash FastMCP server -python server.py -``` - -```bash ngrok -ngrok http 8000 -``` -</CodeGroup> - -<Warning> -This exposes your unauthenticated server to the internet. Only run this command in a safe environment if you understand the risks. -</Warning> - -### Call the Server - -To use the Responses API, you'll need to install the OpenAI Python SDK (not included with FastMCP): - -```bash -pip install openai -``` - -You'll also need to authenticate with OpenAI. You can do this by setting the `OPENAI_API_KEY` environment variable. Consult the OpenAI SDK documentation for more information. - -```bash -export OPENAI_API_KEY="your-api-key" -``` - -Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. - -```python {4, 11-16} -from openai import OpenAI - -# Your server URL (replace with your actual URL) -url = 'https://your-server-url.com' - -client = OpenAI() - -resp = client.responses.create( - model="gpt-4.1", - tools=[ - { - "type": "mcp", - "server_label": "dice_server", - "server_url": f"{url}/mcp/", - "require_approval": "never", - }, - ], - input="Roll a few dice!", -) - -print(resp.output_text) -``` -If you run this code, you'll see something like the following output: - -```text -You rolled 3 dice and got the following results: 6, 4, and 2! -``` - -### Authentication - -<VersionBadge version="2.6.0" /> - -The Responses API can include headers to authenticate the request, which means you don't have to worry about your server being publicly accessible. - -#### Server Authentication - -The simplest way to add authentication to the server is to use a bearer token scheme. - -For this example, we'll quickly generate our own tokens with FastMCP's `RSAKeyPair` utility, but this may not be appropriate for production use. For more details, see the complete server-side [Token Verification](/servers/auth/token-verification) documentation. - -We'll start by creating an RSA key pair to sign and verify tokens. - -```python -from fastmcp.server.auth.providers.jwt import RSAKeyPair - -key_pair = RSAKeyPair.generate() -access_token = key_pair.create_token(audience="dice-server") -``` - -<Warning> -FastMCP's `RSAKeyPair` utility is for development and testing only. -</Warning> - -Next, we'll create a `JWTVerifier` to authenticate the server. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import JWTVerifier - -auth = JWTVerifier( - public_key=key_pair.public_key, - audience="dice-server", -) - -mcp = FastMCP(name="Dice Roller", auth=auth) -``` - -Here is a complete example that you can copy/paste. For simplicity and the purposes of this example only, it will print the token to the console. **Do NOT do this in production!** - -```python server.py [expandable] -from fastmcp import FastMCP -from fastmcp.server.auth import JWTVerifier -from fastmcp.server.auth.providers.jwt import RSAKeyPair -import random - -key_pair = RSAKeyPair.generate() -access_token = key_pair.create_token(audience="dice-server") - -auth = JWTVerifier( - public_key=key_pair.public_key, - audience="dice-server", -) - -mcp = FastMCP(name="Dice Roller", auth=auth) - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - print(f"\n---\n\n🔑 Dice Roller access token:\n\n{access_token}\n\n---\n") - mcp.run(transport="http", port=8000) -``` - -#### Client Authentication - -If you try to call the authenticated server with the same OpenAI code we wrote earlier, you'll get an error like this: - -```text -APIStatusError: Error code: 424 - { - "error": { - "message": "Error retrieving tool list from MCP server: 'dice_server'. Http status code: 401 (Unauthorized)", - "type": "external_connector_error", - "param": "tools", - "code": "http_error" - } -} -``` - -As expected, the server is rejecting the request because it's not authenticated. - -To authenticate the client, you can pass the token in the `Authorization` header with the `Bearer` scheme: - - -```python {4, 7, 19-21} [expandable] -from openai import OpenAI - -# Your server URL (replace with your actual URL) -url = 'https://your-server-url.com' - -# Your access token (replace with your actual token) -access_token = 'your-access-token' - -client = OpenAI() - -resp = client.responses.create( - model="gpt-4.1", - tools=[ - { - "type": "mcp", - "server_label": "dice_server", - "server_url": f"{url}/mcp/", - "require_approval": "never", - "headers": { - "Authorization": f"Bearer {access_token}" - } - }, - ], - input="Roll a few dice!", -) - -print(resp.output_text) -``` - -You should now see the dice roll results in the output. \ No newline at end of file diff --git a/docs/v3/integrations/openapi.mdx b/docs/v3/integrations/openapi.mdx deleted file mode 100644 index f5f2b3dfa..000000000 --- a/docs/v3/integrations/openapi.mdx +++ /dev/null @@ -1,456 +0,0 @@ ---- -title: OpenAPI 🤝 FastMCP -sidebarTitle: OpenAPI -description: Generate MCP servers from any OpenAPI specification -icon: list-tree ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.0.0" /> - -FastMCP can automatically generate an MCP server from any OpenAPI specification, allowing AI models to interact with existing APIs through the MCP protocol. Instead of manually creating tools and resources, you provide an OpenAPI spec and FastMCP intelligently converts API endpoints into the appropriate MCP components. - -<Note> -Under the hood, OpenAPI integration uses OpenAPIProvider (v3.0.0+) to source tools from the specification. See [Providers](/servers/providers/overview) to understand how FastMCP sources components. -</Note> - -<Tip> -Generating MCP servers from OpenAPI is a great way to get started with FastMCP, but in practice LLMs achieve **significantly better performance** with well-designed and curated MCP servers than with auto-converted OpenAPI servers. This is especially true for complex APIs with many endpoints and parameters. - -We recommend using the FastAPI integration for bootstrapping and prototyping, not for mirroring your API to LLM clients. See the post [Stop Converting Your REST APIs to MCP](https://www.jlowin.dev/blog/stop-converting-rest-apis-to-mcp) for more details. -</Tip> - -## Create a Server - -To convert an OpenAPI specification to an MCP server, use the `FastMCP.from_openapi()` class method: - -```python server.py -import httpx -from fastmcp import FastMCP - -# Create an HTTP client for your API -client = httpx.AsyncClient(base_url="https://api.example.com") - -# Load your OpenAPI spec -openapi_spec = httpx.get("https://api.example.com/openapi.json").json() - -# Create the MCP server -mcp = FastMCP.from_openapi( - openapi_spec=openapi_spec, - client=client, - name="My API Server" -) - -if __name__ == "__main__": - mcp.run() -``` - -### Authentication - -If your API requires authentication, configure it on the HTTP client: - -```python -import httpx -from fastmcp import FastMCP - -# Bearer token authentication -api_client = httpx.AsyncClient( - base_url="https://api.example.com", - headers={"Authorization": "Bearer YOUR_TOKEN"} -) - -# Create MCP server with authenticated client -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - timeout=30.0 # 30 second timeout for all requests -) -``` - -## Route Mapping - -By default, FastMCP converts **every endpoint** in your OpenAPI specification into an MCP **Tool**. This provides a simple, predictable starting point that ensures all your API's functionality is immediately available to the vast majority of LLM clients which only support MCP tools. - -While this is a pragmatic default for maximum compatibility, you can easily customize this behavior. Internally, FastMCP uses an ordered list of `RouteMap` objects to determine how to map OpenAPI routes to various MCP component types. - -Each `RouteMap` specifies a combination of methods, patterns, and tags, as well as a corresponding MCP component type. Each OpenAPI route is checked against each `RouteMap` in order, and the first one that matches every criteria is used to determine its converted MCP type. A special type, `EXCLUDE`, can be used to exclude routes from the MCP server entirely. - -- **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all) -- **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all) -- **Tags**: A set of OpenAPI tags that must all be present. An empty set (`{}`) means no tag filtering, so the route matches regardless of its tags. -- **MCP type**: What MCP component type to create (`TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, or `EXCLUDE`) -- **MCP tags**: A set of custom tags to add to components created from matching routes - -Here is FastMCP's default rule: - -```python -from fastmcp.server.providers.openapi import RouteMap, MCPType - -DEFAULT_ROUTE_MAPPINGS = [ - # All routes become tools - RouteMap(mcp_type=MCPType.TOOL), -] -``` - -### Custom Route Maps - -When creating your FastMCP server, you can customize routing behavior by providing your own list of `RouteMap` objects. Your custom maps are processed before the default route maps, and routes will be assigned to the first matching custom map. - -For example, prior to FastMCP 2.8.0, GET requests were automatically mapped to `Resource` and `ResourceTemplate` components based on whether they had path parameters. (This was changed solely for client compatibility reasons.) You can restore this behavior by providing custom route maps: - -```python -from fastmcp import FastMCP -from fastmcp.server.providers.openapi import RouteMap, MCPType - -# Restore pre-2.8.0 semantic mapping -semantic_maps = [ - # GET requests with path parameters become ResourceTemplates - RouteMap(methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE), - # All other GET requests become Resources - RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), -] - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - route_maps=semantic_maps, -) -``` - -With these maps, `GET` requests are handled semantically, and all other methods (`POST`, `PUT`, etc.) will fall through to the default rule and become `Tool`s. - -Here is a more complete example that uses custom route maps to convert all `GET` endpoints under `/analytics/` to tools while excluding all admin endpoints and all routes tagged "internal". All other routes will be handled by the default rules: - -```python -from fastmcp import FastMCP -from fastmcp.server.providers.openapi import RouteMap, MCPType - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - route_maps=[ - # Analytics `GET` endpoints are tools - RouteMap( - methods=["GET"], - pattern=r"^/analytics/.*", - mcp_type=MCPType.TOOL, - ), - - # Exclude all admin endpoints - RouteMap( - pattern=r"^/admin/.*", - mcp_type=MCPType.EXCLUDE, - ), - - # Exclude all routes tagged "internal" - RouteMap( - tags={"internal"}, - mcp_type=MCPType.EXCLUDE, - ), - ], -) -``` - -<Tip> -The default route maps are always applied after your custom maps, so you do not have to create route maps for every possible route. -</Tip> - -### Excluding Routes - -To exclude routes from the MCP server, use a route map to assign them to `MCPType.EXCLUDE`. - -You can use this to remove sensitive or internal routes by targeting them specifically: - -```python -from fastmcp import FastMCP -from fastmcp.server.providers.openapi import RouteMap, MCPType - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - route_maps=[ - RouteMap(pattern=r"^/admin/.*", mcp_type=MCPType.EXCLUDE), - RouteMap(tags={"internal"}, mcp_type=MCPType.EXCLUDE), - ], -) -``` - -Or you can use a catch-all rule to exclude everything that your maps don't handle explicitly: - -```python -from fastmcp import FastMCP -from fastmcp.server.providers.openapi import RouteMap, MCPType - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - route_maps=[ - # custom mapping logic goes here - # ... your specific route maps ... - # exclude all remaining routes - RouteMap(mcp_type=MCPType.EXCLUDE), - ], -) -``` - -<Tip> -Using a catch-all exclusion rule will prevent the default route mappings from being applied, since it will match every remaining route. This is useful if you want to explicitly allow-list certain routes. -</Tip> - -### Advanced Route Mapping - -<VersionBadge version="2.5.0" /> - -For advanced use cases that require more complex logic, you can provide a `route_map_fn` callable. After the route map logic is applied, this function is called on each matched route and its assigned MCP component type. It can optionally return a different component type to override the mapped assignment. If it returns `None`, the assigned type is used. - -In addition to more precise targeting of methods, patterns, and tags, this function can access any additional OpenAPI metadata about the route. - -<Tip> -The `route_map_fn` is called on all routes, even those that matched `MCPType.EXCLUDE` in your custom maps. This gives you an opportunity to customize the mapping or even override an exclusion. -</Tip> - -```python -from fastmcp import FastMCP -from fastmcp.server.providers.openapi import RouteMap, MCPType -from fastmcp.utilities.openapi import HTTPRoute - -def custom_route_mapper(route: HTTPRoute, mcp_type: MCPType) -> MCPType | None: - """Advanced route type mapping.""" - # Convert all admin routes to tools regardless of HTTP method - if "/admin/" in route.path: - return MCPType.TOOL - - elif "internal" in route.tags: - return MCPType.EXCLUDE - - # Convert user detail routes to templates even if they're POST - elif route.path.startswith("/users/") and route.method == "POST": - return MCPType.RESOURCE_TEMPLATE - - # Use defaults for all other routes - return None - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - route_map_fn=custom_route_mapper, -) -``` - -## Customization - -### Component Names - -<VersionBadge version="2.5.0" /> - -FastMCP automatically generates names for MCP components based on the OpenAPI specification. By default, it uses the `operationId` from your OpenAPI spec, up to the first double underscore (`__`). - -All component names are automatically: -- **Slugified**: Spaces and special characters are converted to underscores or removed -- **Truncated**: Limited to 56 characters maximum to ensure compatibility -- **Unique**: If multiple components have the same name, a number is automatically appended to make them unique - -For more control over component names, you can provide an `mcp_names` dictionary that maps `operationId` values to your desired names. The `operationId` must be exactly as it appears in the OpenAPI spec. The provided name will always be slugified and truncated. - -```python -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - mcp_names={ - "list_users__with_pagination": "user_list", - "create_user__admin_required": "create_user", - "get_user_details__admin_required": "user_detail", - } -) -``` - -Any `operationId` not found in `mcp_names` will use the default strategy (operationId up to the first `__`). - -### Tags - -<VersionBadge version="2.8.0" /> - -FastMCP provides several ways to add tags to your MCP components, allowing you to categorize and organize them for better discoverability and filtering. Tags are combined from multiple sources to create the final set of tags on each component. - -#### RouteMap Tags - -You can add custom tags to components created from specific routes using the `mcp_tags` parameter in `RouteMap`. These tags will be applied to all components created from routes that match that particular route map. - -```python -from fastmcp.server.providers.openapi import RouteMap, MCPType - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - route_maps=[ - # Add custom tags to all POST endpoints - RouteMap( - methods=["POST"], - pattern=r".*", - mcp_type=MCPType.TOOL, - mcp_tags={"write-operation", "api-mutation"} - ), - - # Add different tags to detail view endpoints - RouteMap( - methods=["GET"], - pattern=r".*\{.*\}.*", - mcp_type=MCPType.RESOURCE_TEMPLATE, - mcp_tags={"detail-view", "parameterized"} - ), - - # Add tags to list endpoints - RouteMap( - methods=["GET"], - pattern=r".*", - mcp_type=MCPType.RESOURCE, - mcp_tags={"list-data", "collection"} - ), - ], -) -``` - -#### Global Tags - -You can add tags to **all** components by providing a `tags` parameter when creating your MCP server. These global tags will be applied to every component created from your OpenAPI specification. - -```python -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - tags={"api-v2", "production", "external"} -) -``` - -#### OpenAPI Tags in Client Meta - -FastMCP automatically includes OpenAPI tags from your specification in the component's metadata. These tags are available to MCP clients through the `meta.fastmcp.tags` field, allowing clients to filter and organize components based on the original OpenAPI tagging: - -<CodeGroup> -```json {5} OpenAPI spec with tags -{ - "paths": { - "/users": { - "get": { - "tags": ["users", "public"], - "operationId": "list_users", - "summary": "List all users" - } - } - } -} -``` -```python {6-9} Access OpenAPI tags in MCP client -async with client: - tools = await client.list_tools() - for tool in tools: - if tool.meta: - # OpenAPI tags are now available in fastmcp namespace! - fastmcp_meta = tool.meta.get('fastmcp', {}) - openapi_tags = fastmcp_meta.get('tags', []) - if 'users' in openapi_tags: - print(f"Found user-related tool: {tool.name}") -``` -</CodeGroup> - -This makes it easy for clients to understand and organize API endpoints based on their original OpenAPI categorization. - -### Advanced Customization - -<VersionBadge version="2.5.0" /> - -By default, FastMCP creates MCP components using a variety of metadata from the OpenAPI spec, such as incorporating the OpenAPI description into the MCP component description. - -At times you may want to modify those MCP components in a variety of ways, such as adding LLM-specific instructions or tags. For fine-grained customization, you can provide a `mcp_component_fn` when creating the MCP server. After each MCP component has been created, this function is called on it and has the opportunity to modify it in-place. - -<Tip> -Your `mcp_component_fn` is expected to modify the component in-place, not to return a new component. The result of the function is ignored. -</Tip> - -```python -from fastmcp.server.providers.openapi import ( - OpenAPITool, - OpenAPIResource, - OpenAPIResourceTemplate, -) -from fastmcp.utilities.openapi import HTTPRoute - -def customize_components( - route: HTTPRoute, - component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate, -) -> None: - # Add custom tags to all components - component.tags.add("openapi") - - # Customize based on component type - if isinstance(component, OpenAPITool): - component.description = f"🔧 {component.description} (via API)" - - if isinstance(component, OpenAPIResource): - component.description = f"📊 {component.description}" - component.tags.add("data") - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=client, - mcp_component_fn=customize_components, -) -``` - -## Request Parameter Handling - -FastMCP intelligently handles different types of parameters in OpenAPI requests: - -### Query Parameters - -By default, FastMCP only includes query parameters that have non-empty values. Parameters with `None` values or empty strings are automatically filtered out. - -```python -# When calling this tool... -await client.call_tool("search_products", { - "category": "electronics", # ✅ Included - "min_price": 100, # ✅ Included - "max_price": None, # ❌ Excluded - "brand": "", # ❌ Excluded -}) - -# The HTTP request will be: GET /products?category=electronics&min_price=100 -``` - -### Path Parameters - -Path parameters are typically required by REST APIs. FastMCP: -- Filters out `None` values -- Validates that all required path parameters are provided -- Raises clear errors for missing required parameters - -```python -# ✅ This works -await client.call_tool("get_user", {"user_id": 123}) - -# ❌ This raises: "Missing required path parameters: {'user_id'}" -await client.call_tool("get_user", {"user_id": None}) -``` - -### Array Parameters - -FastMCP handles array parameters according to OpenAPI specifications: - -- **Query arrays**: Serialized based on the `explode` parameter (default: `True`) -- **Path arrays**: Serialized as comma-separated values (OpenAPI 'simple' style) - -```python -# Query array with explode=true (default) -# ?tags=red&tags=blue&tags=green - -# Query array with explode=false -# ?tags=red,blue,green - -# Path array (always comma-separated) -# /items/red,blue,green -``` - -### Headers - -Header parameters are automatically converted to strings and included in the HTTP request. \ No newline at end of file diff --git a/docs/v3/integrations/permit.mdx b/docs/v3/integrations/permit.mdx deleted file mode 100644 index 066f5b1ea..000000000 --- a/docs/v3/integrations/permit.mdx +++ /dev/null @@ -1,352 +0,0 @@ ---- -title: Permit.io Authorization 🤝 FastMCP -sidebarTitle: Permit.io -description: Add fine-grained authorization to your FastMCP servers with Permit.io -icon: shield-check ---- - -Add **policy-based authorization** to your FastMCP servers with one-line code addition with the **[Permit.io][permit-github] authorization middleware**. - -Control which tools, resources and prompts MCP clients can view and execute on your server. Define dynamic policies using Permit.io's powerful RBAC, ABAC, and REBAC capabilities, and obtain comprehensive audit logs of all access attempts and violations. - -## How it Works - -Leveraging FastMCP's [Middleware][fastmcp-middleware], the Permit.io middleware intercepts all MCP requests to your server and automatically maps MCP methods to authorization checks against your Permit.io policies; covering both server methods and tool execution. - -### Policy Mapping - -The middleware automatically maps MCP methods to Permit.io resources and actions: - -- **MCP server methods** (e.g., `tools/list`, `resources/read`): - - **Resource**: `{server_name}_{component}` (e.g., `myserver_tools`) - - **Action**: The method verb (e.g., `list`, `read`) -- **Tool execution** (method `tools/call`): - - **Resource**: `{server_name}` (e.g., `myserver`) - - **Action**: The tool name (e.g., `greet`) - -![Permit.io Policy Mapping Example](./images/permit/policy_mapping.png) - -*Example: In Permit.io, the 'Admin' role is granted permissions on resources and actions as mapped by the middleware. For example, 'greet', 'greet-jwt', and 'login' are actions on the 'mcp_server' resource, and 'list' is an action on the 'mcp_server_tools' resource.* - -> **Note:** -> Don't forget to assign the relevant role (e.g., Admin, User) to the user authenticating to your MCP server (such as the user in the JWT) in the Permit.io Directory. Without the correct role assignment, users will not have access to the resources and actions you've configured in your policies. -> -> ![Permit.io Directory Role Assignment Example](./images/permit/role_assignement.png) -> -> *Example: In Permit.io Directory, both 'client' and 'admin' users are assigned the 'Admin' role, granting them the permissions defined in your policy mapping.* - -For detailed policy mapping examples and configuration, see [Detailed Policy Mapping](https://github.com/permitio/permit-fastmcp/blob/main/docs/policy-mapping.md). - -### Listing Operations - -The middleware behaves as a filter for listing operations (`tools/list`, `resources/list`, `prompts/list`), hiding to the client components that are not authorized by the defined policies. - -```mermaid -sequenceDiagram - participant MCPClient as MCP Client - participant PermitMiddleware as Permit.io Middleware - participant MCPServer as FastMCP Server - participant PermitPDP as Permit.io PDP - - MCPClient->>PermitMiddleware: MCP Listing Request (e.g., tools/list) - PermitMiddleware->>MCPServer: MCP Listing Request - MCPServer-->>PermitMiddleware: MCP Listing Response - PermitMiddleware->>PermitPDP: Authorization Checks - PermitPDP->>PermitMiddleware: Authorization Decisions - PermitMiddleware-->>MCPClient: Filtered MCP Listing Response -``` - -### Execution Operations - -The middleware behaves as an enforcement point for execution operations (`tools/call`, `resources/read`, `prompts/get`), blocking operations that are not authorized by the defined policies. - -```mermaid -sequenceDiagram - participant MCPClient as MCP Client - participant PermitMiddleware as Permit.io Middleware - participant MCPServer as FastMCP Server - participant PermitPDP as Permit.io PDP - - MCPClient->>PermitMiddleware: MCP Execution Request (e.g., tools/call) - PermitMiddleware->>PermitPDP: Authorization Check - PermitPDP->>PermitMiddleware: Authorization Decision - PermitMiddleware-->>MCPClient: MCP Unauthorized Error (if denied) - PermitMiddleware->>MCPServer: MCP Execution Request (if allowed) - MCPServer-->>PermitMiddleware: MCP Execution Response (if allowed) - PermitMiddleware-->>MCPClient: MCP Execution Response (if allowed) -``` - -## Add Authorization to Your Server - -<Note> -Permit.io is a cloud-native authorization service. You need a Permit.io account and a running Policy Decision Point (PDP) for the middleware to function. You can run the PDP locally with Docker or use Permit.io's cloud PDP. -</Note> - -### Prerequisites - -1. **Permit.io Account**: Sign up at [permit.io](https://permit.io) -2. **PDP Setup**: Run the Permit.io PDP locally or use the cloud PDP (RBAC only) -3. **API Key**: Get your Permit.io API key from the dashboard - -### Run the Permit.io PDP - -Run the PDP locally with Docker: - -```bash -docker run -p 7766:7766 permitio/pdp:latest -``` - -Or use the cloud PDP URL: `https://cloudpdp.api.permit.io` - -### Create a Server with Authorization - -First, install the `permit-fastmcp` package: - -```bash -# Using UV (recommended) -uv add permit-fastmcp - -# Using pip -pip install permit-fastmcp -``` - -Then create a FastMCP server and add the Permit.io middleware: - -```python server.py -from fastmcp import FastMCP -from permit_fastmcp.middleware.middleware import PermitMcpMiddleware - -mcp = FastMCP("Secure FastMCP Server 🔒") - -@mcp.tool -def greet(name: str) -> str: - """Greet a user by name""" - return f"Hello, {name}!" - -@mcp.tool -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - -# Add Permit.io authorization middleware -mcp.add_middleware(PermitMcpMiddleware( - permit_pdp_url="http://localhost:7766", - permit_api_key="your-permit-api-key" -)) - -if __name__ == "__main__": - mcp.run(transport="http") -``` - -### Configure Access Policies - -Create your authorization policies in the Permit.io dashboard: - -1. **Create Resources**: Define resources like `mcp_server` and `mcp_server_tools` -2. **Define Actions**: Add actions like `greet`, `add`, `list`, `read` -3. **Create Roles**: Define roles like `Admin`, `User`, `Guest` -4. **Assign Permissions**: Grant roles access to specific resources and actions -5. **Assign Users**: Assign roles to users in the Permit.io Directory - -For step-by-step setup instructions and troubleshooting, see [Getting Started & FAQ](https://github.com/permitio/permit-fastmcp/blob/main/docs/getting-started.md). - -#### Example Policy Configuration - -Policies are defined in the Permit.io dashboard, but you can also use the [Permit.io Terraform provider](https://github.com/permitio/terraform-provider-permitio) to define policies in code. - - -```terraform -# Resources -resource "permitio_resource" "mcp_server" { - name = "mcp_server" - key = "mcp_server" - - actions = { - "greet" = { name = "greet" } - "add" = { name = "add" } - } -} - -resource "permitio_resource" "mcp_server_tools" { - name = "mcp_server_tools" - key = "mcp_server_tools" - - actions = { - "list" = { name = "list" } - } -} - -# Roles -resource "permitio_role" "Admin" { - key = "Admin" - name = "Admin" - permissions = [ - "mcp_server:greet", - "mcp_server:add", - "mcp_server_tools:list" - ] -} -``` - -You can also use the [Permit.io CLI](https://github.com/permitio/permit-cli), [API](https://api.permit.io/scalar) or [SDKs](https://github.com/permitio/permit-python) to manage policies, as well as writing policies directly in REGO (Open Policy Agent's policy language). - -For complete policy examples including ABAC and RBAC configurations, see [Example Policies](https://github.com/permitio/permit-fastmcp/tree/main/docs/example_policies). - -### Identity Management - -The middleware supports multiple identity extraction modes: - -- **Fixed Identity**: Use a fixed identity for all requests -- **Header-based**: Extract identity from HTTP headers -- **JWT-based**: Extract and verify JWT tokens -- **Source-based**: Use the MCP context source field - -For detailed identity mode configuration and environment variables, see [Identity Modes & Environment Variables](https://github.com/permitio/permit-fastmcp/blob/main/docs/identity-modes.md). - -#### JWT Authentication Example - -```python -import os - -# Configure JWT identity extraction -os.environ["PERMIT_MCP_IDENTITY_MODE"] = "jwt" -os.environ["PERMIT_MCP_IDENTITY_JWT_SECRET"] = "your-jwt-secret" - -mcp.add_middleware(PermitMcpMiddleware( - permit_pdp_url="http://localhost:7766", - permit_api_key="your-permit-api-key" -)) -``` - -### ABAC Policies with Tool Arguments - -The middleware supports Attribute-Based Access Control (ABAC) policies that can evaluate tool arguments as attributes. Tool arguments are automatically flattened as individual attributes (e.g., `arg_name`, `arg_number`) for granular policy conditions. - -![ABAC Condition Example](./images/permit/abac_condition_example.png) - -*Example: Create dynamic resources with conditions like `resource.arg_number greater-than 10` to allow the `conditional-greet` tool only when the number argument exceeds 10.* - -#### Example: Conditional Access - -Create a dynamic resource with conditions like `resource.arg_number greater-than 10` to allow the `conditional-greet` tool only when the number argument exceeds 10. - -```python -@mcp.tool -def conditional_greet(name: str, number: int) -> str: - """Greet a user only if number > 10""" - return f"Hello, {name}! Your number is {number}" -``` - -![ABAC Policy Example](./images/permit/abac_policy_example.png) - -*Example: The Admin role is granted access to the "conditional-greet" action on the "Big-greets" dynamic resource, while other tools like "greet", "greet-jwt", and "login" are granted on the base "mcp_server" resource.* - -For comprehensive ABAC configuration and advanced policy examples, see [ABAC Policies with Tool Arguments](https://github.com/permitio/permit-fastmcp/blob/main/docs/policy-mapping.md#abac-policies-with-tool-arguments). - -### Run the Server - -Start your FastMCP server normally: - -```bash -python server.py -``` - -The middleware will now intercept all MCP requests and check them against your Permit.io policies. Requests include user identification through the configured identity mode and automatic mapping of MCP methods to authorization resources and actions. - -## Advanced Configuration - -### Environment Variables - -Configure the middleware using environment variables: - -```bash -# Permit.io configuration -export PERMIT_MCP_PERMIT_PDP_URL="http://localhost:7766" -export PERMIT_MCP_PERMIT_API_KEY="your-api-key" - -# Identity configuration -export PERMIT_MCP_IDENTITY_MODE="jwt" -export PERMIT_MCP_IDENTITY_JWT_SECRET="your-jwt-secret" - -# Method configuration -export PERMIT_MCP_KNOWN_METHODS='["tools/list","tools/call"]' -export PERMIT_MCP_BYPASSED_METHODS='["initialize","ping"]' - -# Logging configuration -export PERMIT_MCP_ENABLE_AUDIT_LOGGING="true" -``` - -For a complete list of all configuration options and environment variables, see [Configuration Reference](https://github.com/permitio/permit-fastmcp/blob/main/docs/configuration-reference.md). - -### Custom Middleware Configuration - -```python -from permit_fastmcp.middleware.middleware import PermitMcpMiddleware - -middleware = PermitMcpMiddleware( - permit_pdp_url="http://localhost:7766", - permit_api_key="your-api-key", - enable_audit_logging=True, - bypass_methods=["initialize", "ping", "health/*"] -) - -mcp.add_middleware(middleware) -``` - -For advanced configuration options and custom middleware extensions, see [Advanced Configuration](https://github.com/permitio/permit-fastmcp/blob/main/docs/advanced-configuration.md). - -## Example: Complete JWT Authentication Server - -See the [example server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/example.py) for a full implementation with JWT-based authentication. For additional examples and usage patterns, see [Example Server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/): - -```python -from fastmcp import FastMCP, Context -from permit_fastmcp.middleware.middleware import PermitMcpMiddleware -import jwt -import datetime - -# Configure JWT identity extraction -os.environ["PERMIT_MCP_IDENTITY_MODE"] = "jwt" -os.environ["PERMIT_MCP_IDENTITY_JWT_SECRET"] = "mysecretkey" - -mcp = FastMCP("My MCP Server") - -@mcp.tool -def login(username: str, password: str) -> str: - """Login to get a JWT token""" - if username == "admin" and password == "password": - token = jwt.encode( - {"sub": username, "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)}, - "mysecretkey", - algorithm="HS256" - ) - return f"Bearer {token}" - raise Exception("Invalid credentials") - -@mcp.tool -def greet_jwt(ctx: Context) -> str: - """Greet a user by extracting their name from JWT""" - # JWT extraction handled by middleware - return "Hello, authenticated user!" - -mcp.add_middleware(PermitMcpMiddleware( - permit_pdp_url="http://localhost:7766", - permit_api_key="your-permit-api-key" -)) - -if __name__ == "__main__": - mcp.run(transport="http") -``` - -<Tip> - For detailed policy configuration, custom authentication, and advanced - deployment patterns, visit the [Permit.io FastMCP Middleware - repository][permit-fastmcp-github]. For troubleshooting common issues, see [Troubleshooting](https://github.com/permitio/permit-fastmcp/blob/main/docs/troubleshooting.md). -</Tip> - - -[permit.io]: https://www.permit.io -[permit-github]: https://github.com/permitio -[permit-fastmcp-github]: https://github.com/permitio/permit-fastmcp -[Agent.Security]: https://agent.security -[fastmcp-middleware]: /servers/middleware diff --git a/docs/v3/integrations/propelauth.mdx b/docs/v3/integrations/propelauth.mdx deleted file mode 100644 index 7f21d2010..000000000 --- a/docs/v3/integrations/propelauth.mdx +++ /dev/null @@ -1,164 +0,0 @@ ---- -title: PropelAuth 🤝 FastMCP -sidebarTitle: PropelAuth -description: Secure your FastMCP server with PropelAuth -icon: shield-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<VersionBadge version="3.1.0" /> - -This guide shows you how to secure your FastMCP server using [**PropelAuth**](https://www.propelauth.com), a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where PropelAuth handles user login, consent management, and your FastMCP server validates the tokens. - -## Configuration - -### Prerequisites - -Before you begin, you will need: - -1. A [PropelAuth](https://www.propelauth.com) account -2. Your FastMCP server's base URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Configure PropelAuth - -<Steps> -<Step title="Enable MCP Authentication"> - Navigate to the **MCP** section in your PropelAuth dashboard, click **Enable MCP**, and choose which environments to enable it for (Test, Staging, Prod). -</Step> - -<Step title="Configure Allowed MCP Clients"> - Under **MCP > Allowed MCP Clients**, add redirect URIs for each MCP client you want to allow. PropelAuth provides templates for popular clients like Claude, Cursor, and ChatGPT. -</Step> - -<Step title="Configure Scopes"> - Under **MCP > Scopes**, define the permissions available to MCP clients (e.g., `read:user_data`). -</Step> - -<Step title="Choose How Users Create OAuth Clients"> - Under **MCP > Settings > How Do Users Create OAuth Clients?**, you can optionally enable: - - **Dynamic Client Registration** — clients self-register automatically via the DCR protocol - - **Manually via Hosted Pages** — PropelAuth creates a UI for your users to register OAuth clients - - You can enable neither, one, or both. If you enable neither, you'll manage OAuth client creation yourself. -</Step> - -<Step title="Generate Introspection Credentials"> - Go to **MCP > Request Validation** and click **Create Credentials**. Note the **Client ID** and **Client Secret** - you'll need these to validate tokens. -</Step> - -<Step title="Note Your Auth URL"> - Find your Auth URL in the **Backend Integration** section of the dashboard (e.g., `https://auth.yourdomain.com`). -</Step> -</Steps> - -For more details, see the [PropelAuth MCP documentation](https://docs.propelauth.com/mcp-authentication/overview). - -### Step 2: Environment Setup - -Create a `.env` file with your PropelAuth configuration: - -```bash -PROPELAUTH_AUTH_URL=https://auth.yourdomain.com # From Backend Integration page -PROPELAUTH_INTROSPECTION_CLIENT_ID=your-client-id # From MCP > Request Validation -PROPELAUTH_INTROSPECTION_CLIENT_SECRET=your-client-secret # From MCP > Request Validation -SERVER_URL=http://localhost:8000 # Your server's base URL -``` - -### Step 3: FastMCP Configuration - -Create your FastMCP server file and use the PropelAuthProvider to handle all the OAuth integration automatically: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.propelauth import PropelAuthProvider - -auth_provider = PropelAuthProvider( - auth_url=os.environ["PROPELAUTH_AUTH_URL"], - introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], - introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], - base_url=os.environ["SERVER_URL"], - required_scopes=["read:user_data"], # Optional scope enforcement -) - -mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth_provider) -``` - -## Testing - -With your `.env` loaded, start the server: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Then use a FastMCP client to verify authentication works: - -```python -from fastmcp import Client -import asyncio - -async def main(): - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - assert await client.ping() - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## Accessing User Information - -You can use `get_access_token()` inside your tools to identify the authenticated user: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.propelauth import PropelAuthProvider -from fastmcp.server.dependencies import get_access_token - -auth = PropelAuthProvider( - auth_url=os.environ["PROPELAUTH_AUTH_URL"], - introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], - introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], - base_url=os.environ["SERVER_URL"], - required_scopes=["read:user_data"], -) - -mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth) - -@mcp.tool -def whoami() -> dict: - """Return the authenticated user's ID.""" - token = get_access_token() - if token is None: - return {"error": "Not authenticated"} - user_id = token.claims.get("sub") - return {"user_id": user_id} -``` - -## Advanced Configuration - -The `PropelAuthProvider` supports optional overrides for token introspection behavior, including caching and request timeouts: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.propelauth import PropelAuthProvider - -auth = PropelAuthProvider( - auth_url=os.environ["PROPELAUTH_AUTH_URL"], - introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], - introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], - base_url=os.environ.get("BASE_URL", "https://your-server.com"), - required_scopes=["read:user_data"], - resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707) - token_introspection_overrides={ - "cache_ttl_seconds": 300, # Cache introspection results for 5 minutes - "max_cache_size": 1000, # Maximum cached tokens - "timeout_seconds": 15, # HTTP request timeout - }, -) - -mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth) -``` diff --git a/docs/v3/integrations/pydantic-ai.mdx b/docs/v3/integrations/pydantic-ai.mdx deleted file mode 100644 index 0c8ffa524..000000000 --- a/docs/v3/integrations/pydantic-ai.mdx +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: Pydantic AI 🤝 FastMCP -sidebarTitle: Pydantic AI -description: Connect FastMCP servers to Pydantic AI agents using the FastMCPToolset -icon: message-code ---- - -[Pydantic AI](https://ai.pydantic.dev/) ships a [`FastMCPToolset`](https://ai.pydantic.dev/mcp/fastmcp-client/) that lets a Pydantic AI agent call tools exposed by any MCP server through the [FastMCP Client](/clients/client). Because the toolset is built on the FastMCP Client, it works with FastMCP servers as well as any other MCP server, and supports the full range of [transports](/clients/transports): in-memory, STDIO, Streamable HTTP, and SSE. - -This page shows how to point `FastMCPToolset` at a FastMCP server, with examples for each transport. For the toolset's full API, see the [Pydantic AI documentation](https://ai.pydantic.dev/mcp/fastmcp-client/). - -<Tip> -The `FastMCPToolset` currently exposes **tools** to the agent. Other MCP features such as elicitation and sampling are not yet supported through this toolset; use Pydantic AI's standard [`MCPServer`](https://ai.pydantic.dev/mcp/client/) client if you need them. -</Tip> - -## Install - -`FastMCPToolset` lives in `pydantic-ai-slim` behind the `fastmcp` optional group: - -```bash -pip install "pydantic-ai-slim[fastmcp]" -``` - -## Create a Server - -Create a FastMCP server with the tools you want to expose. We'll use a single dice-rolling tool throughout this guide. - -```python server.py -import random -from fastmcp import FastMCP - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - """Roll `n_dice` 6-sided dice and return the results.""" - return [random.randint(1, 6) for _ in range(n_dice)] - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -## In-Memory - -If your FastMCP server lives in the same process as your agent, pass the `FastMCP` instance directly. The toolset reuses an [in-memory transport](/clients/transports#in-memory-transport), which avoids a network round trip and is the fastest option for tests and embedded use. - -```python -import asyncio -import random -from fastmcp import FastMCP -from pydantic_ai import Agent -from pydantic_ai.toolsets.fastmcp import FastMCPToolset - -mcp = FastMCP(name="Dice Roller") - -@mcp.tool -def roll_dice(n_dice: int) -> list[int]: - return [random.randint(1, 6) for _ in range(n_dice)] - -toolset = FastMCPToolset(mcp) -agent = Agent("openai:gpt-4.1", toolsets=[toolset]) - -async def main(): - result = await agent.run("Roll 3 dice!") - print(result.output) - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## Streamable HTTP - -For a remote FastMCP server reachable over HTTP, pass the URL as a string. The toolset infers the [Streamable HTTP transport](/clients/transports#http-transport) from the URL. - -```python -from pydantic_ai import Agent -from pydantic_ai.toolsets.fastmcp import FastMCPToolset - -toolset = FastMCPToolset("https://your-server-url.com/mcp") -agent = Agent("openai:gpt-4.1", toolsets=[toolset]) -``` - -For [SSE](/clients/transports#sse-transport), use a `/sse` URL instead. - -## STDIO - -To launch a FastMCP server as a subprocess, pass a script path and the toolset will use the [STDIO transport](/clients/transports#stdio-transport). - -```python -from pydantic_ai import Agent -from pydantic_ai.toolsets.fastmcp import FastMCPToolset - -toolset = FastMCPToolset("server.py") -agent = Agent("openai:gpt-4.1", toolsets=[toolset]) -``` - -You can also pass a [`StdioTransport`](/clients/transports#stdio-transport) directly when you need control over the command, args, or environment. - -## MCP Configuration - -To wire up multiple servers at once, pass an [MCP configuration](/integrations/mcp-json-configuration) dictionary. The toolset opens one client per server and exposes all of their tools to the agent. - -```python -from pydantic_ai import Agent -from pydantic_ai.toolsets.fastmcp import FastMCPToolset - -mcp_config = { - "mcpServers": { - "dice": {"command": "python", "args": ["server.py"]}, - "weather": {"url": "https://weather.example.com/mcp"}, - } -} - -toolset = FastMCPToolset(mcp_config) -agent = Agent("openai:gpt-4.1", toolsets=[toolset]) -``` - -## Authentication - -Because `FastMCPToolset` wraps a [FastMCP `Client`](/clients/client), it inherits the client's full [authentication](/clients/auth/bearer) story. To pass credentials such as a bearer token to a remote server, build a `Client` (or `StreamableHttpTransport`) yourself and hand it to the toolset. - -```python -from fastmcp import Client -from fastmcp.client.transports import StreamableHttpTransport -from pydantic_ai import Agent -from pydantic_ai.toolsets.fastmcp import FastMCPToolset - -transport = StreamableHttpTransport( - url="https://your-server-url.com/mcp", - headers={"Authorization": "Bearer your-access-token"}, -) - -toolset = FastMCPToolset(Client(transport)) -agent = Agent("openai:gpt-4.1", toolsets=[toolset]) -``` - -For OAuth flows, use FastMCP's [`OAuth` helper](/clients/auth/oauth) when constructing the `Client`. For server-side token verification, see [Token Verification](/servers/auth/token-verification). diff --git a/docs/v3/integrations/scalekit.mdx b/docs/v3/integrations/scalekit.mdx deleted file mode 100644 index 191b81ca2..000000000 --- a/docs/v3/integrations/scalekit.mdx +++ /dev/null @@ -1,155 +0,0 @@ ---- -title: Scalekit 🤝 FastMCP -sidebarTitle: Scalekit -description: Secure your FastMCP server with Scalekit -icon: shield-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.13.0" /> - -Install auth stack to your FastMCP server with [Scalekit](https://scalekit.com) using the [Remote OAuth](/servers/auth/remote-oauth) pattern: Scalekit handles user authentication, and the MCP server validates issued tokens. - -### Prerequisites - -Before you begin - -1. Get a [Scalekit account](https://app.scalekit.com/) and grab your **Environment URL** from _Dashboard > Settings_ . -2. Have your FastMCP server's base URL ready (can be localhost for development, e.g., `http://localhost:8000/`) - -### Step 1: Configure MCP server in Scalekit environment - -<Steps> -<Step title="Register MCP server and set environment"> - -In your Scalekit dashboard: - 1. Open the **MCP Servers** section, then select **Create new server** - 2. Enter server details: a name, a resource identifier, and the desired MCP client authentication settings - 3. Save, then copy the **Resource ID** (for example, res_92015146095) - -In your FastMCP project's `.env`: - -```sh -SCALEKIT_ENVIRONMENT_URL=<YOUR_APP_ENVIRONMENT_URL> -SCALEKIT_RESOURCE_ID=<YOUR_APP_RESOURCE_ID> # res_926EXAMPLE5878 -BASE_URL=http://localhost:8000/ -# Optional: additional scopes tokens must have -# SCALEKIT_REQUIRED_SCOPES=read,write -``` - -</Step> -</Steps> - -### Step 2: Add auth to FastMCP server - -Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically: - -> **Warning:** The legacy `mcp_url` and `client_id` parameters are deprecated and will be removed in a future release. Use `base_url` instead of `mcp_url` and remove `client_id` from your configuration. - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.scalekit import ScalekitProvider - -# Discovers Scalekit endpoints and set up JWT token validation -auth_provider = ScalekitProvider( - environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL - resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID - base_url=SERVER_URL, # Public MCP endpoint - required_scopes=["read"], # Optional scope enforcement -) - -# Create FastMCP server with auth -mcp = FastMCP(name="My Scalekit Protected Server", auth=auth_provider) - -@mcp.tool -def auth_status() -> dict: - """Show Scalekit authentication status.""" - # Extract user claims from the JWT - return { - "message": "This tool requires authentication via Scalekit", - "authenticated": True, - "provider": "Scalekit" - } - -``` - -<Tip> -Set `required_scopes` when you need tokens to carry specific permissions. Leave it unset to allow any token issued for the resource. -</Tip> - -## Testing - -### Start the MCP server - -```sh -uv run python server.py -``` - -Use any MCP client (for example, mcp-inspector, Claude, VS Code, or Windsurf) to connect to the running serve. Verify that authentication succeeds and requests are authorized as expected. - -## Production Configuration - -For production deployments, load configuration from environment variables: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.scalekit import ScalekitProvider - -# Load configuration from environment variables -auth = ScalekitProvider( - environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"), - resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"), - base_url=os.environ.get("BASE_URL", "https://your-server.com") -) - -mcp = FastMCP(name="My Scalekit Protected Server", auth=auth) - -@mcp.tool -def protected_action() -> str: - """A tool that requires authentication.""" - return "Access granted via Scalekit!" -``` - -## Capabilities - -Scalekit supports OAuth 2.1 with Dynamic Client Registration for MCP clients and enterprise SSO, and provides built‑in JWT validation and security controls. - -**OAuth 2.1/DCR**: clients self‑register, use PKCE, and work with the Remote OAuth pattern without pre‑provisioned credentials. - -**Validation and SSO**: tokens are verified (keys, RS256, issuer, audience, expiry), and SAML, OIDC, OAuth 2.0, ADFS, Azure AD, and Google Workspace are supported; use HTTPS in production and review auth logs as needed. - -## Debugging - -Enable detailed logging to troubleshoot authentication issues: - -```python -import logging -logging.basicConfig(level=logging.DEBUG) -``` - -### Token inspection - -You can inspect JWT tokens in your tools to understand the user context: - -```python -from fastmcp.server.context import request_ctx -import jwt - -@mcp.tool -def inspect_token() -> dict: - """Inspect the current JWT token claims.""" - context = request_ctx.get() - - # Extract token from Authorization header - if hasattr(context, 'request') and hasattr(context.request, 'headers'): - auth_header = context.request.headers.get('authorization', '') - if auth_header.startswith('Bearer '): - token = auth_header[7:] - # Decode without verification (already verified by provider) - claims = jwt.decode(token, options={"verify_signature": False}) - return claims - - return {"error": "No token found"} -``` diff --git a/docs/v3/integrations/supabase.mdx b/docs/v3/integrations/supabase.mdx deleted file mode 100644 index 9ffda444d..000000000 --- a/docs/v3/integrations/supabase.mdx +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: Supabase 🤝 FastMCP -sidebarTitle: Supabase -description: Secure your FastMCP server with Supabase Auth -icon: shield-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.13.0" /> - -This guide shows you how to secure your FastMCP server using **Supabase Auth**. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where Supabase handles user authentication and your FastMCP server validates the tokens. - -<Warning> -Supabase Auth does not currently support [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators, so FastMCP cannot validate that tokens were issued for the specific resource server. -</Warning> - -## Consent UI Requirement - -Supabase's OAuth Server delegates the user consent screen to your application. When an MCP client initiates authorization, Supabase authenticates the user and then redirects to your application at a configured callback URL (e.g., `https://your-app.com/oauth/callback?authorization_id=...`). Your application must host a page that calls Supabase's `approveAuthorization()` or `denyAuthorization()` APIs to complete the flow. - -`SupabaseProvider` handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page. - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. A **[Supabase Account](https://supabase.com/)** with a project or a self-hosted **Supabase Auth** instance -2. **OAuth Server enabled** in your Supabase Dashboard (Authentication → OAuth Server) -3. **Dynamic Client Registration enabled** in the same settings -4. A **consent UI** hosted at your configured authorization path (see above) -5. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Enable Supabase OAuth Server - -In your Supabase Dashboard: -1. Go to **Authentication → OAuth Server** -2. Enable the **OAuth Server** -3. Set your **Site URL** to where your consent UI is hosted -4. Set the **Authorization Path** (e.g., `/oauth/callback`) -5. Enable **Allow Dynamic OAuth Apps** for MCP client registration - -### Step 2: Get Supabase Project URL - -In your Supabase Dashboard: -1. Go to **Project Settings** -2. Copy your **Project URL** (e.g., `https://abc123.supabase.co`) - -### Step 3: FastMCP Configuration - -Create your FastMCP server using the `SupabaseProvider`: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.supabase import SupabaseProvider - -auth = SupabaseProvider( - project_url="https://abc123.supabase.co", - base_url="http://localhost:8000", -) - -mcp = FastMCP("Supabase Protected Server", auth=auth) - -@mcp.tool -def protected_tool(message: str) -> str: - """This tool requires authentication.""" - return f"Authenticated user says: {message}" - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -## Testing - -### Running the Server - -Start your FastMCP server with HTTP transport to enable OAuth flows: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -### Testing with a Client - -Create a test client that authenticates with your Supabase-protected server: - -```python client.py -from fastmcp import Client -import asyncio - -async def main(): - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - print("Authenticated with Supabase!") - - result = await client.call_tool("protected_tool", {"message": "Hello!"}) - print(result) - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to Supabase's authorization endpoint -2. After authenticating, Supabase redirects to your consent UI -3. After you approve, the client receives the token and can make authenticated requests - -## Production Configuration - -For production deployments, load configuration from environment variables: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.supabase import SupabaseProvider - -auth = SupabaseProvider( - project_url=os.environ["SUPABASE_PROJECT_URL"], - base_url=os.environ.get("BASE_URL", "https://your-server.com"), -) - -mcp = FastMCP(name="Supabase Secured App", auth=auth) -``` diff --git a/docs/v3/integrations/workos.mdx b/docs/v3/integrations/workos.mdx deleted file mode 100644 index 4f13a5512..000000000 --- a/docs/v3/integrations/workos.mdx +++ /dev/null @@ -1,200 +0,0 @@ ---- -title: WorkOS 🤝 FastMCP -sidebarTitle: WorkOS -description: Authenticate FastMCP servers with WorkOS Connect -icon: shield-check ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.12.0" /> - -Secure your FastMCP server with WorkOS Connect authentication. This integration uses the OAuth Proxy pattern to handle authentication through WorkOS Connect while maintaining compatibility with MCP clients. - -<Note> -This guide covers WorkOS Connect applications. For Dynamic Client Registration (DCR) with AuthKit, see the [AuthKit integration](/integrations/authkit) instead. -</Note> - -## Configuration - -### Prerequisites - -Before you begin, you will need: -1. A **[WorkOS Account](https://workos.com/)** with access to create OAuth Apps -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) - -### Step 1: Create a WorkOS OAuth App - -Create an OAuth App in your WorkOS dashboard to get the credentials needed for authentication: - -<Steps> -<Step title="Create OAuth Application"> -In your WorkOS dashboard: -1. Navigate to **Applications** -2. Click **Create Application** -3. Select **OAuth Application** -4. Name your application -</Step> - -<Step title="Get Credentials"> -In your OAuth application settings: -1. Copy your **Client ID** (starts with `client_`) -2. Click **Generate Client Secret** and save it securely -3. Copy your **AuthKit Domain** (e.g., `https://your-app.authkit.app`) -</Step> - -<Step title="Configure Redirect URI"> -In the **Redirect URIs** section: -- Add: `http://localhost:8000/auth/callback` (for development) -- For production, add your server's public URL + `/auth/callback` - -<Warning> -The callback URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. -</Warning> -</Step> -</Steps> - -### Step 2: FastMCP Configuration - -Create your FastMCP server using the `WorkOSProvider`: - -```python server.py -from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider - -# Configure WorkOS OAuth -auth = WorkOSProvider( - client_id="client_YOUR_CLIENT_ID", - client_secret="YOUR_CLIENT_SECRET", - authkit_domain="https://your-app.authkit.app", - base_url="http://localhost:8000", - required_scopes=["openid", "profile", "email"] -) - -mcp = FastMCP("WorkOS Protected Server", auth=auth) - -@mcp.tool -def protected_tool(message: str) -> str: - """This tool requires authentication.""" - return f"Authenticated user says: {message}" - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -## Testing - -### Running the Server - -Start your FastMCP server with HTTP transport to enable OAuth flows: - -```bash -fastmcp run server.py --transport http --port 8000 -``` - -Your server is now running and protected by WorkOS OAuth authentication. - -### Testing with a Client - -Create a test client that authenticates with your WorkOS-protected server: - -```python client.py -from fastmcp import Client -import asyncio - -async def main(): - # The client will automatically handle WorkOS OAuth - async with Client("http://localhost:8000/mcp", auth="oauth") as client: - # First-time connection will open WorkOS login in your browser - print("✓ Authenticated with WorkOS!") - - # Test the protected tool - result = await client.call_tool("protected_tool", {"message": "Hello!"}) - print(result) - -if __name__ == "__main__": - asyncio.run(main()) -``` - -When you run the client for the first time: -1. Your browser will open to WorkOS's authorization page -2. After you authorize the app, you'll be redirected back -3. The client receives the token and can make authenticated requests - -<Info> -The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. -</Info> - -## Production Configuration - -<VersionBadge version="2.13.0" /> - -For production deployments with persistent token management across server restarts, configure `jwt_signing_key`, and `client_storage`: - -```python server.py -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -# Production setup with encrypted persistent token storage -auth = WorkOSProvider( - client_id="client_YOUR_CLIENT_ID", - client_secret="YOUR_CLIENT_SECRET", - authkit_domain="https://your-app.authkit.app", - base_url="https://your-production-domain.com", - required_scopes=["openid", "profile", "email"], - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore( - host=os.environ["REDIS_HOST"], - port=int(os.environ["REDIS_PORT"]) - ), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) - -mcp = FastMCP(name="Production WorkOS App", auth=auth) -``` - -<Note> -Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. - -For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). -</Note> - -## Configuration Options - -<Card> -<ParamField path="client_id" required> -WorkOS OAuth application client ID -</ParamField> - -<ParamField path="client_secret" required> -WorkOS OAuth application client secret -</ParamField> - -<ParamField path="authkit_domain" required> -Your WorkOS AuthKit domain URL (e.g., `https://your-app.authkit.app`) -</ParamField> - -<ParamField path="base_url" required> -Your FastMCP server's public URL -</ParamField> - -<ParamField path="required_scopes" default="[]"> -OAuth scopes to request -</ParamField> - -<ParamField path="redirect_path" default="/auth/callback"> -OAuth callback path -</ParamField> - -<ParamField path="timeout_seconds" default="10"> -API request timeout -</ParamField> -</Card> \ No newline at end of file diff --git a/docs/v3/more/faq.mdx b/docs/v3/more/faq.mdx deleted file mode 100644 index d2bbbe05e..000000000 --- a/docs/v3/more/faq.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: FAQ -description: Answers to common questions about installing and using FastMCP -icon: circle-question ---- - -## `import fastmcp` stopped working after I upgraded with pip - -This can happen when you upgrade to FastMCP 3.3 or later from FastMCP 3.2 or earlier with `pip`. The quick fix is `pip install --force-reinstall fastmcp`. See [Troubleshooting](/getting-started/installation#troubleshooting) for the clean-reinstall fallback and an explanation of why it happens. - -## What's the difference between `fastmcp` and `fastmcp-slim`? - -`fastmcp` is the full distribution. Installing it gives you the complete framework — server, client, CLI, and the common integrations — and is the right choice for most users: - -```bash -pip install fastmcp -``` - -`fastmcp-slim` ships the same importable `fastmcp` package with a minimal set of required dependencies. You opt into the pieces you need through extras, which keeps environments lean when you only use part of the framework: - -```bash -pip install "fastmcp-slim[client]" -``` - -Both distributions expose the same `import fastmcp`, so application code is identical regardless of which one you install. diff --git a/docs/v3/more/settings.mdx b/docs/v3/more/settings.mdx deleted file mode 100644 index af6b862fe..000000000 --- a/docs/v3/more/settings.mdx +++ /dev/null @@ -1,99 +0,0 @@ ---- -title: Settings -description: Configure FastMCP behavior through environment variables or a .env file. -icon: gear ---- - -FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file (see the [Tasks (Docket)](#tasks-docket) section for a caveat about nested settings in `.env` files). - -```bash -# Set via environment -export FASTMCP_LOG_LEVEL=DEBUG -export FASTMCP_PORT=3000 - -# Or use a .env file (loaded automatically) -echo "FASTMCP_LOG_LEVEL=DEBUG" >> .env -``` - -You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` environment variable (defaults to `.env`). Because this controls which file is loaded, it must be set as an environment variable — it cannot be set inside a `.env` file itself. - -## Logging - -| Environment Variable | Type | Default | Description | -|---|---|---|---| -| `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level for FastMCP's own logging output. Case-insensitive. | -| `FASTMCP_LOG_ENABLED` | `bool` | `true` | Enable or disable FastMCP logging entirely. | -| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. | -| `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. | -| `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. | -| `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. | - -## Transport & HTTP - -These control how the server listens when running with an HTTP transport. - -| Environment Variable | Type | Default | Description | -|---|---|---|---| -| `FASTMCP_TRANSPORT` | `Literal["stdio", "http", "sse", "streamable-http"]` | `stdio` | Default transport. | -| `FASTMCP_HOST` | `str` | `127.0.0.1` | Host to bind to. | -| `FASTMCP_PORT` | `int` | `8000` | Port to bind to. | -| `FASTMCP_SSE_PATH` | `str` | `/sse` | Path for SSE endpoint. | -| `FASTMCP_MESSAGE_PATH` | `str` | `/messages/` | Path for SSE message endpoint. | -| `FASTMCP_STREAMABLE_HTTP_PATH` | `str` | `/mcp` | Path for Streamable HTTP endpoint. | -| `FASTMCP_STATELESS_HTTP` | `bool` | `false` | Enable stateless HTTP mode (new transport per request). Useful for multi-worker deployments. | -| `FASTMCP_JSON_RESPONSE` | `bool` | `false` | Use JSON responses instead of SSE for Streamable HTTP. | -| `FASTMCP_HTTP_HOST_ORIGIN_PROTECTION` | `bool \| "auto"` | `false` | Validate `Host` and browser `Origin` headers for Streamable HTTP requests. `auto` protects localhost-bound servers and explicit host/origin allowlists. | -| `FASTMCP_HTTP_ALLOWED_HOSTS` | `list[str] \| null` | `null` | Additional trusted hostnames when Host and Origin protection is enabled. Use a JSON array, such as `["mcp.example.com"]`. | -| `FASTMCP_HTTP_ALLOWED_ORIGINS` | `list[str] \| null` | `null` | Browser origins trusted when Host and Origin protection is enabled. Configure CORS separately for cross-origin browser reads. Use a JSON array, such as `["https://app.example.com"]`. | -| `FASTMCP_DEBUG` | `bool` | `false` | Enable debug mode. | - -## Error Handling - -| Environment Variable | Type | Default | Description | -|---|---|---|---| -| `FASTMCP_MASK_ERROR_DETAILS` | `bool` | `false` | Mask error details before sending to clients. When enabled, only messages from explicitly raised `ToolError`, `ResourceError`, or `PromptError` are included in responses. | -| `FASTMCP_STRICT_INPUT_VALIDATION` | `bool` | `false` | Strictly validate tool inputs against the JSON schema. When disabled, compatible inputs are coerced (e.g., the string `"10"` becomes the integer `10`). | -| `FASTMCP_MOUNTED_COMPONENTS_RAISE_ON_LOAD_ERROR` | `bool` | `false` | Raise errors when loading mounted components instead of logging warnings. | - -## Client - -| Environment Variable | Type | Default | Description | -|---|---|---|---| -| `FASTMCP_CLIENT_INIT_TIMEOUT` | `float \| None` | None | Timeout in seconds for the client initialization handshake. Set to `0` or leave unset to disable. | -| `FASTMCP_CLIENT_DISCONNECT_TIMEOUT` | `float` | `5` | Maximum time in seconds to wait for a clean disconnect before giving up. | -| `FASTMCP_CLIENT_RAISE_FIRST_EXCEPTIONGROUP_ERROR` | `bool` | `true` | When an `ExceptionGroup` is raised, re-raise the first error directly instead of the group. Simplifies debugging but may mask secondary errors. | - -## CLI & Display - -| Environment Variable | Type | Default | Description | -|---|---|---|---| -| `FASTMCP_SHOW_SERVER_BANNER` | `bool` | `true` | Show the server banner on startup. Also controllable via `--no-banner` or `server.run(show_banner=False)`. | -| `FASTMCP_CHECK_FOR_UPDATES` | `Literal["stable", "prerelease", "off"]` | `stable` | Update checking on CLI startup. `stable` checks stable releases only, `prerelease` includes pre-releases, `off` disables checking. | - -## Tasks (Docket) - -These configure the [Docket](https://github.com/prefecthq/docket) task queue used by [server tasks](/servers/tasks). All use the `FASTMCP_DOCKET_` prefix. - -<Warning> -When setting Docket values in a `.env` file, use a **double** underscore: `FASTMCP_DOCKET__URL` (not `FASTMCP_DOCKET_URL`). This is because `.env` values are resolved through the parent `Settings` class, which uses `__` as its nested delimiter. As regular environment variables (e.g., `export`), the single-underscore form `FASTMCP_DOCKET_URL` works fine. -</Warning> - -| Environment Variable | Type | Default | Description | -|---|---|---|---| -| `FASTMCP_DOCKET_NAME` | `str` | `fastmcp` | Queue name. Servers and workers sharing the same name and backend URL share a task queue. | -| `FASTMCP_DOCKET_URL` | `str` | `memory://` | Backend URL. Use `memory://` for single-process or `redis://host:port/db` for distributed workers. | -| `FASTMCP_DOCKET_WORKER_NAME` | `str \| None` | None | Worker name. Auto-generated if unset. | -| `FASTMCP_DOCKET_CONCURRENCY` | `int` | `10` | Maximum concurrent tasks per worker. | -| `FASTMCP_DOCKET_REDELIVERY_TIMEOUT` | `timedelta` | `300s` | If a worker doesn't complete a task within this time, it's redelivered to another worker. | -| `FASTMCP_DOCKET_RECONNECTION_DELAY` | `timedelta` | `5s` | Delay between reconnection attempts when the worker loses its backend connection. | -| `FASTMCP_DOCKET_MINIMUM_CHECK_INTERVAL` | `timedelta` | `50ms` | How frequently the worker polls for new tasks. Lower values reduce latency at the cost of more CPU usage. | - -## Advanced - -| Environment Variable | Type | Default | Description | -|---|---|---|---| -| `FASTMCP_HOME` | `Path` | Platform default | Data directory for FastMCP. Defaults to the platform-specific user data directory. | -| `FASTMCP_ENV_FILE` | `str` | `.env` | Path to the `.env` file to load settings from. Must be set as an environment variable (see above). | -| `FASTMCP_SERVER_DEPENDENCIES` | `list[str]` | `[]` | Additional dependencies to install in the server environment. | -| `FASTMCP_DECORATOR_MODE` | `Literal["function", "object"]` | `function` | Controls what `@tool`, `@resource`, and `@prompt` decorators return. `function` returns the original function (default); `object` returns component objects (deprecated, will be removed). | -| `FASTMCP_TEST_MODE` | `bool` | `false` | Enable test mode. | diff --git a/docs/v3/patterns/contrib.mdx b/docs/v3/patterns/contrib.mdx deleted file mode 100644 index 04ef45aff..000000000 --- a/docs/v3/patterns/contrib.mdx +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "Contrib Modules" -description: "Community-contributed modules extending FastMCP" -icon: "cubes" ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.2.1" /> - -FastMCP includes a `contrib` package that holds community-contributed modules. These modules extend FastMCP's functionality but aren't officially maintained by the core team. - -Contrib modules provide additional features, integrations, or patterns that complement the core FastMCP library. They offer a way for the community to share useful extensions while keeping the core library focused and maintainable. - -The available modules can be viewed in the [contrib directory](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/contrib). - -## Usage - -To use a contrib module, import it from the `fastmcp.contrib` package: - -```python test="skip" -from fastmcp.contrib import my_module -``` - -## Important Considerations - -- **Stability**: Modules in `contrib` may have different testing requirements or stability guarantees compared to the core library. -- **Compatibility**: Changes to core FastMCP might break modules in `contrib` without explicit warnings in the main changelog. -- **Dependencies**: Contrib modules may have additional dependencies not required by the core library. These dependencies are typically documented in the module's README or separate requirements files. - -## Contributing - -We welcome contributions to the `contrib` package! If you have a module that extends FastMCP in a useful way, consider contributing it: - -1. Create a new directory in `fastmcp_slim/fastmcp/contrib/` for your module -3. Add proper tests for your module in `tests/contrib/` -2. Include comprehensive documentation in a README.md file, including usage and examples, as well as any additional dependencies or installation instructions -5. Submit a pull request - -The ideal contrib module: -- Solves a specific use case or integration need -- Follows FastMCP coding standards -- Includes thorough documentation and examples -- Has comprehensive tests -- Specifies any additional dependencies diff --git a/docs/v3/servers/auth/authentication.mdx b/docs/v3/servers/auth/authentication.mdx deleted file mode 100644 index d37c57f36..000000000 --- a/docs/v3/servers/auth/authentication.mdx +++ /dev/null @@ -1,252 +0,0 @@ ---- -title: Authentication -sidebarTitle: Overview -description: Secure your FastMCP server with flexible authentication patterns, from simple API keys to full OAuth 2.1 integration with external identity providers. -icon: user-shield ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.11.0" /> - -Authentication in MCP presents unique challenges that differ from traditional web applications. MCP clients need to discover authentication requirements automatically, negotiate OAuth flows without user intervention, and work seamlessly across different identity providers. FastMCP addresses these challenges by providing authentication patterns that integrate with the MCP protocol while remaining simple to implement and deploy. - -<Tip> -Authentication applies only to FastMCP's HTTP-based transports (`http` and `sse`). The STDIO transport inherits security from its local execution environment. -</Tip> - -<Warning> -**Authentication is rapidly evolving in MCP.** The specification and best practices are changing quickly. FastMCP aims to provide stable, secure patterns that adapt to these changes while keeping your code simple and maintainable. -</Warning> - -## MCP Authentication Challenges - -Traditional web authentication assumes a human user with a browser who can interact with login forms and consent screens. MCP clients are often automated systems that need to authenticate without human intervention. This creates several unique requirements: - -**Automatic Discovery**: MCP clients must discover authentication requirements by examining server metadata rather than encountering login redirects. - -**Programmatic OAuth**: OAuth flows must work without human interaction, relying on pre-configured credentials or Dynamic Client Registration. - -**Token Management**: Clients need to obtain, refresh, and manage tokens automatically across multiple MCP servers. - -**Protocol Integration**: Authentication must integrate cleanly with MCP's transport mechanisms and error handling. - -These challenges mean that not all authentication approaches work well with MCP. The patterns that do work fall into three categories based on the level of authentication responsibility your server assumes. - -## Authentication Responsibility - -Authentication responsibility exists on a spectrum. Your MCP server can validate tokens created elsewhere, coordinate with external identity providers, or handle the complete authentication lifecycle internally. Each approach involves different trade-offs between simplicity, security, and control. - -### Token Validation - -Your server validates tokens but delegates their creation to external systems. This approach treats your MCP server as a pure resource server that trusts tokens signed by known issuers. - -Token validation works well when you already have authentication infrastructure that can issue structured tokens like JWTs. Your existing API gateway, microservices platform, or enterprise SSO system becomes the source of truth for user identity, while your MCP server focuses on its core functionality. - -The key insight is that token validation separates authentication (proving who you are) from authorization (determining what you can do). Your MCP server receives proof of identity in the form of a signed token and makes access decisions based on the claims within that token. - -This pattern excels in microservices architectures where multiple services need to validate the same tokens, or when integrating MCP servers into existing systems that already handle user authentication. - -### External Identity Providers - -Your server coordinates with established identity providers to create seamless authentication experiences for MCP clients. This approach leverages OAuth 2.0 and OpenID Connect protocols to delegate user authentication while maintaining control over authorization decisions. - -External identity providers handle the complex aspects of authentication: user credential verification, multi-factor authentication, account recovery, and security monitoring. Your MCP server receives tokens from these trusted providers and validates them using the provider's public keys. - -The MCP protocol's support for Dynamic Client Registration makes this pattern particularly powerful. MCP clients can automatically discover your authentication requirements and register themselves with your identity provider without manual configuration. - -This approach works best for production applications that need enterprise-grade authentication features without the complexity of building them from scratch. It scales well across multiple applications and provides consistent user experiences. - -### Full OAuth Implementation - -Your server implements a complete OAuth 2.0 authorization server, handling everything from user credential verification to token lifecycle management. This approach provides maximum control at the cost of significant complexity. - -Full OAuth implementation means building user interfaces for login and consent, implementing secure credential storage, managing token lifecycles, and maintaining ongoing security updates. The complexity extends beyond initial implementation to include threat monitoring, compliance requirements, and keeping pace with evolving security best practices. - -This pattern makes sense only when you need complete control over the authentication process, operate in air-gapped environments, or have specialized requirements that external providers cannot meet. - -## FastMCP Authentication Providers - -FastMCP translates these authentication responsibility levels into a variety of concrete classes that handle the complexities of MCP protocol integration. You can build on these classes to handle the complexities of MCP protocol integration. - -### TokenVerifier - -`TokenVerifier` provides pure token validation without OAuth metadata endpoints. This class focuses on the essential task of determining whether a token is valid and extracting authorization information from its claims. - -The implementation handles JWT signature verification, expiration checking, and claim extraction. It validates tokens against known issuers and audiences, ensuring that tokens intended for your server are not accepted by other systems. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.jwt import JWTVerifier - -auth = JWTVerifier( - jwks_uri="https://your-auth-system.com/.well-known/jwks.json", - issuer="https://your-auth-system.com", - audience="your-mcp-server" -) - -mcp = FastMCP(name="Protected Server", auth=auth) -``` - -This example configures token validation against a JWT issuer. The `JWTVerifier` will fetch public keys from the JWKS endpoint and validate incoming tokens against those keys. Only tokens with the correct issuer and audience claims will be accepted. - -`TokenVerifier` works well when you control both the token issuer and your MCP server, or when integrating with existing JWT-based infrastructure. - -→ **Complete guide**: [Token Verification](/servers/auth/token-verification) - -### RemoteAuthProvider - -`RemoteAuthProvider` enables authentication with identity providers that **support Dynamic Client Registration (DCR)**, such as Descope and WorkOS AuthKit. With DCR, MCP clients can automatically register themselves with the identity provider and obtain credentials without any manual configuration. - -This class combines token validation with OAuth discovery metadata. It extends `TokenVerifier` functionality by adding OAuth 2.0 protected resource endpoints that advertise your authentication requirements. MCP clients examine these endpoints to understand which identity providers you trust and how to obtain valid tokens. - -The key requirement is that your identity provider must support DCR - the ability for clients to dynamically register and obtain credentials. This is what enables the seamless, automated authentication flow that MCP requires. - -For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully supports DCR: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider - -auth = AuthKitProvider( - authkit_domain="https://your-project.authkit.app", - base_url="https://your-fastmcp-server.com" -) - -mcp = FastMCP(name="Enterprise Server", auth=auth) -``` - -This example uses WorkOS AuthKit as the external identity provider. The `AuthKitProvider` automatically configures token validation against WorkOS and provides the OAuth metadata that MCP clients need for automatic authentication. - -`RemoteAuthProvider` is ideal for production applications when your identity provider supports Dynamic Client Registration (DCR). This enables fully automated authentication without manual client configuration. - -→ **Complete guide**: [Remote OAuth](/servers/auth/remote-oauth) - -### OAuthProxy - -<VersionBadge version="2.12.0" /> - -`OAuthProxy` enables authentication with OAuth providers that **don't support Dynamic Client Registration (DCR)**, such as GitHub, Google, Azure, AWS, and most traditional enterprise identity systems. - -When identity providers require manual app registration and fixed credentials, `OAuthProxy` bridges the gap. It presents a DCR-compliant interface to MCP clients (accepting any registration request) while using your pre-registered credentials with the upstream provider. The proxy handles the complexity of callback forwarding, enabling dynamic client callbacks to work with providers that require fixed redirect URIs. - -This class solves the fundamental incompatibility between MCP's expectation of dynamic registration and traditional OAuth providers' requirement for manual app registration. - -For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with GitHub's OAuth system: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider - -auth = GitHubProvider( - client_id="Ov23li...", # Your GitHub OAuth App ID - client_secret="abc123...", # Your GitHub OAuth App Secret - base_url="https://your-server.com" -) - -mcp = FastMCP(name="GitHub-Protected Server", auth=auth) -``` - -This example uses the GitHub provider, which extends `OAuthProxy` with GitHub-specific token validation. The proxy handles the complete OAuth flow while making GitHub's non-DCR authentication work seamlessly with MCP clients. - -`OAuthProxy` is essential when integrating with OAuth providers that don't support DCR. This includes most established providers like GitHub, Google, and Azure, which require manual app registration through their developer consoles. - -→ **Complete guide**: [OAuth Proxy](/servers/auth/oauth-proxy) - -### OAuthProvider - -`OAuthProvider` implements a complete OAuth 2.0 authorization server within your MCP server. This class handles the full authentication lifecycle from user credential verification to token management. - -The implementation provides all required OAuth endpoints including authorization, token, and discovery endpoints. It manages client registration, user consent, and token lifecycle while integrating with your user storage and authentication logic. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import OAuthProvider - -auth = MyOAuthProvider( - user_store=your_user_database, - client_store=your_client_registry, - # Additional configuration... -) - -mcp = FastMCP(name="Auth Server", auth=auth) -``` - -This example shows the basic structure of a custom OAuth provider. The actual implementation requires significant additional configuration for user management, client registration, and security policies. - -`OAuthProvider` should be used only when you have specific requirements that external providers cannot meet and the expertise to implement OAuth securely. - -→ **Complete guide**: [Full OAuth Server](/servers/auth/full-oauth-server) - -### MultiAuth - -<VersionBadge version="3.1.0" /> - -`MultiAuth` composes multiple authentication sources into a single `auth` provider. When a server needs to accept tokens from different issuers — for example, an OAuth proxy for interactive clients alongside JWT verification for machine-to-machine tokens — `MultiAuth` tries each source in order and accepts the first successful verification. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import MultiAuth, OAuthProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier - -auth = MultiAuth( - server=OAuthProxy( - issuer_url="https://login.example.com/...", - client_id="my-app", - client_secret="secret", - base_url="https://my-server.com", - ), - verifiers=[ - JWTVerifier( - jwks_uri="https://internal-issuer.example.com/.well-known/jwks.json", - issuer="https://internal-issuer.example.com", - audience="my-mcp-server", - ), - ], -) - -mcp = FastMCP("My Server", auth=auth) -``` - -The server (if provided) owns all OAuth routes and metadata. Verifiers contribute only token verification logic. This keeps the MCP discovery surface clean while supporting multiple token sources. - -→ **Complete guide**: [Multiple Auth Sources](/servers/auth/multi-auth) - -## Configuration - -Authentication providers are configured programmatically by instantiating them directly in your code with their required parameters. This makes dependencies explicit and allows your IDE to provide helpful autocompletion and type checking. - -For production deployments, load sensitive values like client secrets from environment variables: - -```python -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider - -# Load secrets from environment variables -auth = GitHubProvider( - client_id=os.environ.get("GITHUB_CLIENT_ID"), - client_secret=os.environ.get("GITHUB_CLIENT_SECRET"), - base_url=os.environ.get("BASE_URL", "http://localhost:8000") -) - -mcp = FastMCP(name="My Server", auth=auth) -``` - -This approach keeps secrets out of your codebase while maintaining explicit configuration. You can use any environment variable names you prefer - there are no special prefixes required. - -## Choosing Your Implementation - -The authentication approach you choose depends on your existing infrastructure, security requirements, and operational constraints. - -**For OAuth providers without DCR support (GitHub, Google, Azure, AWS, most enterprise systems), use OAuth Proxy.** These providers require manual app registration through their developer consoles. OAuth Proxy bridges the gap by presenting a DCR-compliant interface to MCP clients while using your fixed credentials with the provider. The proxy's callback forwarding pattern enables dynamic client ports to work with providers that require fixed redirect URIs. - -**For identity providers with DCR support (Descope, WorkOS AuthKit, modern auth platforms), use RemoteAuthProvider.** These providers allow clients to dynamically register and obtain credentials without manual configuration. This enables the fully automated authentication flow that MCP is designed for, providing the best user experience and simplest implementation. - -**Token validation works well when you already have authentication infrastructure that issues structured tokens.** If your organization already uses JWT-based systems, API gateways, or enterprise SSO that can generate tokens, this approach integrates seamlessly while keeping your MCP server focused on its core functionality. The simplicity comes from leveraging existing investment in authentication infrastructure. - -**When you need tokens from multiple sources, use MultiAuth.** This is common in hybrid architectures where interactive clients authenticate through an OAuth proxy while backend services send JWT tokens directly. `MultiAuth` composes an optional auth server with additional token verifiers, trying each source in order until one succeeds. - -**Full OAuth implementation should be avoided unless you have compelling reasons that external providers cannot address.** Air-gapped environments, specialized compliance requirements, or unique organizational constraints might justify this approach, but it requires significant security expertise and ongoing maintenance commitment. The complexity extends far beyond initial implementation to include threat monitoring, security updates, and keeping pace with evolving attack vectors. - -FastMCP's architecture supports migration between these approaches as your requirements evolve. You can integrate with existing token systems initially and migrate to external identity providers as your application scales, or implement custom solutions when your requirements outgrow standard patterns. \ No newline at end of file diff --git a/docs/v3/servers/auth/full-oauth-server.mdx b/docs/v3/servers/auth/full-oauth-server.mdx deleted file mode 100644 index 529a01784..000000000 --- a/docs/v3/servers/auth/full-oauth-server.mdx +++ /dev/null @@ -1,229 +0,0 @@ ---- -title: Full OAuth Server -sidebarTitle: Full OAuth Server -description: Build a self-contained authentication system where your FastMCP server manages users, issues tokens, and validates them. -icon: users-between-lines - ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.11.0" /> - -<Warning> -**This is an extremely advanced pattern that most users should avoid.** Building a secure OAuth 2.1 server requires deep expertise in authentication protocols, cryptography, and security best practices. The complexity extends far beyond initial implementation to include ongoing security monitoring, threat response, and compliance maintenance. - -**Use [Remote OAuth](/servers/auth/remote-oauth) instead** unless you have compelling requirements that external identity providers cannot meet, such as air-gapped environments or specialized compliance needs. -</Warning> - -The Full OAuth Server pattern exists to support the MCP protocol specification's requirements. Your FastMCP server becomes both an Authorization Server and Resource Server, handling the complete authentication lifecycle from user login to token validation. - -This documentation exists for completeness - the vast majority of applications should use external identity providers instead. - -## OAuthProvider - -FastMCP provides the `OAuthProvider` abstract class that implements the OAuth 2.1 specification. To use this pattern, you must subclass `OAuthProvider` and implement all required abstract methods. - -<Note> -`OAuthProvider` handles OAuth endpoints, protocol flows, and security requirements, but delegates all storage, user management, and business logic to your implementation of the abstract methods. -</Note> - -## Required Implementation - -You must implement these abstract methods to create a functioning OAuth server: - -### Client Management - -<Card icon="code" title="Client Management Methods"> -<ParamField body="get_client" type="async method"> - Retrieve client information by ID from your database. - - <Expandable title="Parameters"> - <ParamField body="client_id" type="str"> - Client identifier to look up - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="OAuthClientInformationFull | None" type="return type"> - Client information object or `None` if client not found - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="register_client" type="async method"> - Store new client registration information in your database. - - <Expandable title="Parameters"> - <ParamField body="client_info" type="OAuthClientInformationFull"> - Complete client registration information to store - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="None" type="return type"> - No return value - </ParamField> - </Expandable> -</ParamField> -</Card> - -### Authorization Flow - -<Card icon="code" title="Authorization Flow Methods"> -<ParamField body="authorize" type="async method"> - Handle authorization request and return redirect URL. Must implement user authentication and consent collection. - - <Expandable title="Parameters"> - <ParamField body="client" type="OAuthClientInformationFull"> - OAuth client making the authorization request - </ParamField> - <ParamField body="params" type="AuthorizationParams"> - Authorization request parameters from the client - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="str" type="return type"> - Redirect URL to send the client to - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="load_authorization_code" type="async method"> - Load authorization code from storage by code string. Return `None` if code is invalid or expired. - - <Expandable title="Parameters"> - <ParamField body="client" type="OAuthClientInformationFull"> - OAuth client attempting to use the authorization code - </ParamField> - <ParamField body="authorization_code" type="str"> - Authorization code string to look up - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="AuthorizationCode | None" type="return type"> - Authorization code object or `None` if not found - </ParamField> - </Expandable> -</ParamField> -</Card> - -### Token Management - -<Card icon="code" title="Token Management Methods"> -<ParamField body="exchange_authorization_code" type="async method"> - Exchange authorization code for access and refresh tokens. Must validate code and create new tokens. - - <Expandable title="Parameters"> - <ParamField body="client" type="OAuthClientInformationFull"> - OAuth client exchanging the authorization code - </ParamField> - <ParamField body="authorization_code" type="AuthorizationCode"> - Valid authorization code object to exchange - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="OAuthToken" type="return type"> - New OAuth token containing access and refresh tokens - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="load_refresh_token" type="async method"> - Load refresh token from storage by token string. Return `None` if token is invalid or expired. - - <Expandable title="Parameters"> - <ParamField body="client" type="OAuthClientInformationFull"> - OAuth client attempting to use the refresh token - </ParamField> - <ParamField body="refresh_token" type="str"> - Refresh token string to look up - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="RefreshToken | None" type="return type"> - Refresh token object or `None` if not found - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="exchange_refresh_token" type="async method"> - Exchange refresh token for new access/refresh token pair. Must validate scopes and token. - - <Expandable title="Parameters"> - <ParamField body="client" type="OAuthClientInformationFull"> - OAuth client using the refresh token - </ParamField> - <ParamField body="refresh_token" type="RefreshToken"> - Valid refresh token object to exchange - </ParamField> - <ParamField body="scopes" type="list[str]"> - Requested scopes for the new access token - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="OAuthToken" type="return type"> - New OAuth token with updated access and refresh tokens - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="load_access_token" type="async method"> - Load an access token by its token string. - - <Expandable title="Parameters"> - <ParamField body="token" type="str"> - The access token to verify - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="AccessToken | None" type="return type"> - The access token object, or `None` if the token is invalid - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="revoke_token" type="async method"> - Revoke access or refresh token, marking it as invalid in storage. - - <Expandable title="Parameters"> - <ParamField body="token" type="AccessToken | RefreshToken"> - Token object to revoke and mark invalid - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="None" type="return type"> - No return value - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="verify_token" type="async method"> - Verify bearer token for incoming requests. Return `AccessToken` if valid, `None` if invalid. - - <Expandable title="Parameters"> - <ParamField body="token" type="str"> - Bearer token string from incoming request - </ParamField> - </Expandable> - - <Expandable title="Returns"> - <ParamField body="AccessToken | None" type="return type"> - Access token object if valid, `None` if invalid or expired - </ParamField> - </Expandable> -</ParamField> -</Card> - -Each method must handle storage, validation, security, and error cases according to the OAuth 2.1 specification. The implementation complexity is substantial and requires expertise in OAuth security considerations. - -<Warning> -**Security Notice:** OAuth server implementation involves numerous security considerations including PKCE, state parameters, redirect URI validation, token binding, replay attack prevention, and secure storage requirements. Mistakes can lead to serious security vulnerabilities. -</Warning> \ No newline at end of file diff --git a/docs/v3/servers/auth/multi-auth.mdx b/docs/v3/servers/auth/multi-auth.mdx deleted file mode 100644 index ba54d25ab..000000000 --- a/docs/v3/servers/auth/multi-auth.mdx +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Multiple Auth Sources -sidebarTitle: Multiple Auth Sources -description: Accept tokens from multiple authentication sources with a single server. -icon: layer-group ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="3.1.0" /> - -Production servers often need to accept tokens from multiple authentication sources. An interactive application might authenticate through an OAuth proxy, while a backend service sends machine-to-machine JWT tokens directly. `MultiAuth` composes these sources into a single `auth` provider so every valid token is accepted regardless of where it was issued. - -## Understanding MultiAuth - -`MultiAuth` wraps an optional auth server (like `OAuthProxy`) together with one or more token verifiers (like `JWTVerifier`). When a request arrives with a bearer token, `MultiAuth` tries each source in order and accepts the first successful verification. - -The auth server, if provided, is tried first. It owns all OAuth routes and metadata — the verifiers contribute only token verification logic. This keeps the MCP discovery surface clean: one set of routes, one set of metadata, multiple verification paths. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import MultiAuth, OAuthProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier - -auth = MultiAuth( - server=OAuthProxy( - issuer_url="https://login.example.com/...", - client_id="my-app", - client_secret="secret", - base_url="https://my-server.com", - ), - verifiers=[ - JWTVerifier( - jwks_uri="https://internal-issuer.example.com/.well-known/jwks.json", - issuer="https://internal-issuer.example.com", - audience="my-mcp-server", - ), - ], -) - -mcp = FastMCP("My Server", auth=auth) -``` - -Interactive MCP clients authenticate through the OAuth proxy as usual. Backend services skip OAuth entirely and send a JWT signed by the internal issuer. Both paths are validated, and the first match wins. - -## Verification Order - -`MultiAuth` checks sources in a deterministic order: - -1. **Server** (if provided) — the full auth provider's `verify_token` runs first -2. **Verifiers** — each `TokenVerifier` is tried in list order - -The first source that returns a valid `AccessToken` wins. If every source returns `None`, the request receives a 401 response. - -This ordering means the server acts as the "primary" authentication path, with verifiers as fallbacks for tokens the server doesn't recognize. - -## Verifiers Only - -You don't always need a full OAuth server. If your server only needs to accept tokens from multiple issuers, pass verifiers without a server: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import MultiAuth -from fastmcp.server.auth.providers.jwt import JWTVerifier, StaticTokenVerifier - -auth = MultiAuth( - verifiers=[ - JWTVerifier( - jwks_uri="https://issuer-a.example.com/.well-known/jwks.json", - issuer="https://issuer-a.example.com", - audience="my-server", - ), - JWTVerifier( - jwks_uri="https://issuer-b.example.com/.well-known/jwks.json", - issuer="https://issuer-b.example.com", - audience="my-server", - ), - ], -) - -mcp = FastMCP("Multi-Issuer Server", auth=auth) -``` - -Without a server, no OAuth routes or metadata are served. This is appropriate for internal systems where clients already know how to obtain tokens. - -## API Reference - -### MultiAuth - -| Parameter | Type | Description | -| --- | --- | --- | -| `server` | `AuthProvider \| None` | Optional auth provider that owns routes and OAuth metadata. Also tried first for token verification. | -| `verifiers` | `list[TokenVerifier] \| TokenVerifier` | One or more token verifiers tried after the server. | -| `base_url` | `str \| None` | Override the base URL. Defaults to the server's `base_url`. | -| `required_scopes` | `list[str] \| None` | Override required scopes. Defaults to the server's scopes. | diff --git a/docs/v3/servers/auth/oauth-proxy.mdx b/docs/v3/servers/auth/oauth-proxy.mdx deleted file mode 100644 index 79ba7bf09..000000000 --- a/docs/v3/servers/auth/oauth-proxy.mdx +++ /dev/null @@ -1,743 +0,0 @@ ---- -title: OAuth Proxy -sidebarTitle: OAuth Proxy -description: Bridge traditional OAuth providers to work seamlessly with MCP's authentication flow. -icon: share ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<VersionBadge version="2.12.0" /> - -The OAuth proxy enables FastMCP servers to authenticate with OAuth providers that **don't support Dynamic Client Registration (DCR)**. This includes virtually all traditional OAuth providers: GitHub, Google, Azure, AWS, Discord, Facebook, and most enterprise identity systems. For providers that do support DCR (like Descope and WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. - -MCP clients expect to register automatically and obtain credentials on the fly, but traditional providers require manual app registration through their developer consoles. The OAuth proxy bridges this gap by presenting a DCR-compliant interface to MCP clients while using your pre-registered credentials with the upstream provider. When a client attempts to register, the proxy returns your fixed credentials. When a client initiates authorization, the proxy handles the complexity of callback forwarding—storing the client's dynamic callback URL, using its own fixed callback with the provider, then forwarding back to the client after token exchange. - -This approach enables any MCP client (whether using random localhost ports or fixed URLs like Claude.ai) to authenticate with any traditional OAuth provider, all while maintaining full OAuth 2.1 and PKCE security. - -<Note> - For providers that support OIDC discovery (Auth0, Google with OIDC - configuration, Azure AD), consider using [`OIDC - Proxy`](/servers/auth/oidc-proxy) for automatic configuration. OIDC Proxy - extends the OAuth proxy to automatically discover endpoints from the provider's - `/.well-known/openid-configuration` URL, simplifying setup. -</Note> - -## Implementation - -### Provider Setup Requirements - -Before using the OAuth proxy, you need to register your application with your OAuth provider: - -1. **Register your application** in the provider's developer console (GitHub Settings, Google Cloud Console, Azure Portal, etc.) -2. **Configure the redirect URI** as your FastMCP server URL plus your chosen callback path: - - Default: `https://your-server.com/auth/callback` - - Custom: `https://your-server.com/your/custom/path` (if you set `redirect_path`) - - Development: `http://localhost:8000/auth/callback` -3. **Obtain your credentials**: Client ID and Client Secret -4. **Note the OAuth endpoints**: Authorization URL and Token URL (usually found in the provider's OAuth documentation) - -<Warning> - The redirect URI you configure with your provider must exactly match your - FastMCP server's URL plus the callback path. If you customize `redirect_path` - in the OAuth proxy, update your provider's redirect URI accordingly. -</Warning> - -### Basic Setup - -Here's how to implement the OAuth proxy with any provider: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import OAuthProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier - -# Configure token verification for your provider -# See the Token Verification guide for provider-specific setups -token_verifier = JWTVerifier( - jwks_uri="https://your-provider.com/.well-known/jwks.json", - issuer="https://your-provider.com", - audience="your-app-id" -) - -# Create the OAuth proxy -auth = OAuthProxy( - # Provider's OAuth endpoints (from their documentation) - upstream_authorization_endpoint="https://provider.com/oauth/authorize", - upstream_token_endpoint="https://provider.com/oauth/token", - - # Your registered app credentials - upstream_client_id="your-client-id", - upstream_client_secret="your-client-secret", - - # Token validation (see Token Verification guide) - token_verifier=token_verifier, - - # Your FastMCP server's public URL - base_url="https://your-server.com", - - # Optional: customize the callback path (default is "/auth/callback") - # redirect_path="/custom/callback", -) - -mcp = FastMCP(name="My Server", auth=auth) -``` - -### Configuration Parameters - -<Card icon="code" title="OAuthProxy Parameters"> -<ParamField body="upstream_authorization_endpoint" type="str" required> - URL of your OAuth provider's authorization endpoint (e.g., `https://github.com/login/oauth/authorize`) -</ParamField> - -<ParamField body="upstream_token_endpoint" type="str" required> - URL of your OAuth provider's token endpoint (e.g., - `https://github.com/login/oauth/access_token`) -</ParamField> - -<ParamField body="upstream_client_id" type="str" required> - Client ID from your registered OAuth application -</ParamField> - -<ParamField body="upstream_client_secret" type="str | None"> - Client secret from your registered OAuth application. Optional for PKCE public - clients or when using alternative credentials (e.g., managed identity client - assertions via a subclass). When omitted, `jwt_signing_key` must be provided - explicitly since it cannot be derived from the secret. -</ParamField> - -<ParamField body="token_verifier" type="TokenVerifier" required> - A [`TokenVerifier`](/servers/auth/token-verification) instance to validate the - provider's tokens -</ParamField> - -<ParamField body="base_url" type="AnyHttpUrl | str" required> - Public URL where OAuth endpoints will be accessible, **including any mount path** (e.g., `https://your-server.com/api`). - - This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level). -</ParamField> - -<ParamField body="resource_base_url" type="AnyHttpUrl | str | None"> - Optional public base URL for the protected resource metadata and token audience. - - Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL. -</ParamField> - -<ParamField body="redirect_path" type="str" default="/auth/callback"> - Path for OAuth callbacks. Must match the redirect URI configured in your OAuth - application -</ParamField> - -<ParamField body="upstream_revocation_endpoint" type="str | None"> - Optional URL of provider's token revocation endpoint -</ParamField> - -<ParamField body="issuer_url" type="AnyHttpUrl | str | None"> - Issuer URL for OAuth authorization server metadata (defaults to `base_url`). - - When `issuer_url` has a path component (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`. - - **Default behavior (recommended for most cases):** - ```python - auth = GitHubProvider( - base_url="http://localhost:8000/api", # OAuth endpoints under /api - # issuer_url defaults to base_url - path-aware discovery works automatically - ) - ``` - - **When to set explicitly:** - Set `issuer_url` to root level only if you want multiple MCP servers to share a single discovery endpoint: - ```python - auth = GitHubProvider( - base_url="http://localhost:8000/api", - issuer_url="http://localhost:8000" # Shared root-level discovery - ) - ``` - - See the [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for complete mounting examples. -</ParamField> - -<ParamField body="service_documentation_url" type="AnyHttpUrl | str | None"> - Optional URL to your service documentation -</ParamField> - -<ParamField body="forward_pkce" type="bool" default="True"> - Whether to forward PKCE (Proof Key for Code Exchange) to the upstream OAuth - provider. When enabled and the client uses PKCE, the proxy generates its own - PKCE parameters to send upstream while separately validating the client's - PKCE. This ensures end-to-end PKCE security at both layers (client-to-proxy - and proxy-to-upstream). - `True` (default): Forward PKCE for providers that - support it (Google, Azure, AWS, GitHub, etc.) - `False`: Disable only if upstream - provider doesn't support PKCE -</ParamField> - -<ParamField body="forward_resource" type="bool" default="True"> - Whether to forward RFC 8707 `resource` parameters from MCP clients to the - upstream OAuth provider. When enabled, the proxy includes the resource indicator - in authorization requests, allowing providers that support RFC 8707 to scope - tokens to specific resources. Disable for providers that reject unknown - parameters. -</ParamField> - -<ParamField body="token_endpoint_auth_method" type="str | None"> - Token endpoint authentication method for the upstream OAuth server. Controls - how the proxy authenticates when exchanging authorization codes and refresh - tokens with the upstream provider. - `"client_secret_basic"`: Send credentials - in Authorization header (most common) - `"client_secret_post"`: Send - credentials in request body (required by some providers) - `"none"`: No - authentication (for public clients) - `None` (default): Uses authlib's default - (typically `"client_secret_basic"`) Set this if your provider requires a - specific authentication method and the default doesn't work. -</ParamField> - -<ParamField body="allowed_client_redirect_uris" type="list[str] | None"> - List of allowed redirect URI patterns for MCP clients. Patterns support - wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`). - - `None` (default): DCR clients use registered redirect URIs, with loopback - ports allowed to vary for MCP compatibility. Unsafe browser schemes such as - `javascript:`, `data:`, `file:`, and `vbscript:` are rejected. - - Empty list `[]`: No redirect URIs allowed - - Custom list: Only matching patterns allowed - - These patterns apply to MCP client loopback redirects. Configure the upstream - OAuth app redirect URI separately with `redirect_path`. -</ParamField> - -<ParamField body="valid_scopes" type="list[str] | None"> - List of all possible valid scopes for the OAuth provider. These are advertised - to clients through the `/.well-known` endpoints. Defaults to `required_scopes` - from your TokenVerifier if not specified. -</ParamField> - -<ParamField body="extra_authorize_params" type="dict[str, str] | None"> - Additional parameters to forward to the upstream authorization endpoint. Useful for provider-specific parameters that aren't part of the standard OAuth2 flow. - - For example, Auth0 requires an `audience` parameter to issue JWT tokens: - ```python - extra_authorize_params={"audience": "https://api.example.com"} - ``` - - These parameters are added to every authorization request sent to the upstream provider. -</ParamField> - -<ParamField body="extra_token_params" type="dict[str, str] | None"> - Additional parameters to forward to the upstream token endpoint during code exchange and token refresh. Useful for provider-specific requirements during token operations. - -For example, some providers require additional context during token exchange: - -```python -extra_token_params={"audience": "https://api.example.com"} -``` - -These parameters are included in all token requests to the upstream provider. - -</ParamField> - -<ParamField body="client_storage" type="AsyncKeyValue | None"> - -<VersionBadge version="2.13.0" /> - Storage backend for persisting OAuth client registrations and upstream tokens. - - **Default behavior:** - By default, clients are automatically persisted to an encrypted disk store, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly. The disk store is encrypted using a key derived from the JWT Signing Key (which is derived from the upstream client secret by default). For client registrations to survive upstream client secret rotation, you should provide a JWT Signing Key or your own client_storage. - -For production deployments with multiple servers or cloud deployments, see [Storage Backends](/servers/storage-backends) for available options. - -<Warning> - **When providing custom storage**, wrap it in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest: - - ```python - from key_value.aio.stores.redis import RedisStore - from key_value.aio.wrappers.encryption import FernetEncryptionWrapper - from cryptography.fernet import Fernet - import os - - auth = OAuthProxy( - ..., - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore(host="redis.example.com", port=6379), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) - ) - ``` - - Without encryption, upstream OAuth tokens are stored in plaintext. -</Warning> - -Testing with in-memory storage (unencrypted): - -```python -from key_value.aio.stores.memory import MemoryStore - -# Use in-memory storage for testing (clients lost on restart) -auth = OAuthProxy(..., client_storage=MemoryStore()) -``` - -</ParamField> - -<ParamField body="jwt_signing_key" type="str | bytes | None"> - -<VersionBadge version="2.13.0" /> - Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF. - - **Default behavior (`None`):** - Derives a 32-byte key using PBKDF2 from the upstream client secret. - - **For production:** - Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the key derived from the upstream client secret. This allows you to manage keys securely in cloud environments, allows keys to work across multiple instances, and allows you to rotate keys without losing client registrations. - - ```python - import os - - auth = OAuthProxy( - ..., - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], # Any sufficiently complex string! - client_storage=RedisStore(...) # Persistent storage - ) - ``` - - See [HTTP Deployment - OAuth Token Security](/deployment/http#oauth-token-security) for complete production setup. -</ParamField> - - -<ParamField body="require_authorization_consent" type='bool | Literal["remember", "external"]' default="True"> - Consent screen behavior for authorization requests. The consent page displays which client is requesting access, defending against [confused deputy and AS-in-the-middle attacks](#confused-deputy-attacks) by requiring explicit user approval. - - **`True` (default) — always prompt:** - Users see the consent screen on every authorization. Strongest protection against AS-in-the-middle attacks where a malicious MCP server redirects the victim's browser into a legitimate proxy and relies on a previously-remembered approval to silently complete the flow. - - **`"remember"` — silent consent on return:** - Users see the consent screen on first authorization; subsequent flows from the same browser for the same `(client_id, redirect_uri)` are silently approved via a signed cookie. Cross-site navigations (detected via `Sec-Fetch-Site`) fall back to the prompt. `Sec-Fetch-Site` is a browser-level heuristic rather than a protocol guarantee: an attacker who finds a way to initiate a non-cross-site navigation (XSS on a sibling origin, a same-site redirect chain, etc.) can reach the silent-consent path. `True` does not depend on this signal. See [Confused Deputy Attacks](#confused-deputy-attacks) for the underlying attack class. - - **`"external"` — externally managed:** - Follows the same authorization path as `False`: FastMCP skips its consent page and associated browser-binding protections, then redirects directly to the upstream provider. The difference is logging. `False` emits a security warning, while `"external"` suppresses that warning as an explicit acknowledgment that the operator is enforcing equivalent consent and transaction-binding protections elsewhere. FastMCP does not provide or verify those external protections. - - Ordinary upstream OAuth consent is generally not equivalent. It typically authorizes FastMCP's shared upstream application without identifying the downstream MCP client or binding approval to that client's transaction. Use `"external"` only when your surrounding authorization system supplies those protections. - - **`False` — disable entirely:** - Authorization proceeds directly to the upstream provider without any consent UI. Logs a security warning. Only for local development or testing. - - ```python - # Development/testing only - skip consent screen - auth = OAuthProxy( - ..., - require_authorization_consent=False # ⚠️ Security warning: only for local/testing - ) - - # Convenience mode - silent consent on return visits (less safe than True) - auth = OAuthProxy( - ..., - require_authorization_consent="remember", - ) - ``` - - <Warning> - Both `False` and `"external"` disable FastMCP's consent and browser-binding protections. `False` warns about this configuration; `"external"` suppresses the warning because it is an operator acknowledgment that equivalent protections exist elsewhere. Prefer the default `True` unless you own that external authorization flow. - </Warning> -</ParamField> - -<ParamField body="consent_csp_policy" type="str | None" default="None"> - Content Security Policy for the consent page. - - - `None` (default): Uses the built-in CSP policy with appropriate directives for form submission - - Empty string `""`: Disables CSP entirely (no meta tag rendered) - - Custom string: Uses the provided value as the CSP policy - - This is useful for organizations that have their own CSP policies and need to override or disable FastMCP's built-in CSP directives. - - ```python - # Disable CSP entirely (let org CSP policies apply) - auth = OAuthProxy(..., consent_csp_policy="") - - # Use custom CSP policy - auth = OAuthProxy(..., consent_csp_policy="default-src 'self'; style-src 'unsafe-inline'") - ``` -</ParamField> -</Card> - -### Using Built-in Providers - -FastMCP includes pre-configured providers for common services: - -```python -from fastmcp.server.auth.providers.github import GitHubProvider - -auth = GitHubProvider( - client_id="your-github-app-id", - client_secret="your-github-app-secret", - base_url="https://your-server.com" -) - -mcp = FastMCP(name="My Server", auth=auth) -``` - -Available providers include `GitHubProvider`, `GoogleProvider`, and others. These handle token verification automatically. - -### Token Verification - -The OAuth proxy requires a compatible `TokenVerifier` to validate tokens from your provider. Different providers use different token formats: - -- **JWT tokens** (Google, Azure): Use `JWTVerifier` with the provider's JWKS endpoint -- **Opaque tokens with RFC 7662 introspection** (Auth0, Okta, WorkOS): Use `IntrospectionTokenVerifier` -- **Opaque tokens (provider-specific)** (GitHub, Discord): Use provider-specific verifiers like `GitHubTokenVerifier` - -See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider. - -### Scope Configuration - -OAuth scopes control what permissions your application requests from users. They're configured through your `TokenVerifier` (required for the OAuth proxy to validate tokens from your provider). Set `required_scopes` to automatically request the permissions your application needs: - -```python -JWTVerifier(..., required_scopes = ["read:user", "write:data"]) -``` - -Dynamic clients created by the proxy will automatically include these scopes in their authorization requests. See the [Token Verification](#token-verification) section below for detailed setup. - -### Custom Parameters - -Some OAuth providers require additional parameters beyond the standard OAuth2 flow. Use `extra_authorize_params` and `extra_token_params` to pass provider-specific requirements. For example, Auth0 requires an `audience` parameter to issue JWT tokens instead of opaque tokens: - -```python -auth = OAuthProxy( - upstream_authorization_endpoint="https://your-domain.auth0.com/authorize", - upstream_token_endpoint="https://your-domain.auth0.com/oauth/token", - upstream_client_id="your-auth0-client-id", - upstream_client_secret="your-auth0-client-secret", - - # Auth0-specific audience parameter - extra_authorize_params={"audience": "https://your-api-identifier.com"}, - extra_token_params={"audience": "https://your-api-identifier.com"}, - - token_verifier=JWTVerifier( - jwks_uri="https://your-domain.auth0.com/.well-known/jwks.json", - issuer="https://your-domain.auth0.com/", - audience="https://your-api-identifier.com" - ), - base_url="https://your-server.com" -) -``` - -The proxy also forwards RFC 8707 `resource` parameters from MCP clients to upstream providers that support them. This is enabled by default via the `forward_resource` parameter. Disable it for providers that reject unknown parameters. - -## OAuth Flow - -```mermaid -sequenceDiagram - participant Client as MCP Client<br/>(localhost:random) - participant User as User - participant Proxy as FastMCP OAuth Proxy<br/>(server:8000) - participant Provider as OAuth Provider<br/>(GitHub, etc.) - - Note over Client, Proxy: Dynamic Registration (Local) - Client->>Proxy: 1. POST /register<br/>redirect_uri: localhost:54321/callback - Proxy-->>Client: 2. Returns fixed upstream credentials - - Note over Client, User: Authorization with User Consent - Client->>Proxy: 3. GET /authorize<br/>redirect_uri=localhost:54321/callback<br/>code_challenge=CLIENT_CHALLENGE - Note over Proxy: Store transaction with client PKCE<br/>Generate proxy PKCE pair - Proxy->>User: 4. Show consent page<br/>(client details, redirect URI, scopes) - User->>Proxy: 5. Approve/deny consent - Note over Proxy: Set consent binding cookie<br/>(binds browser to this flow) - Proxy->>Provider: 6. Redirect to provider<br/>redirect_uri=server:8000/auth/callback<br/>code_challenge=PROXY_CHALLENGE - - Note over Provider, Proxy: Provider Callback - Provider->>Proxy: 7. GET /auth/callback<br/>with authorization code - Note over Proxy: Verify consent binding cookie<br/>(reject if missing or mismatched) - Proxy->>Provider: 8. Exchange code for tokens<br/>code_verifier=PROXY_VERIFIER - Provider-->>Proxy: 9. Access & refresh tokens - - Note over Proxy, Client: Client Callback Forwarding - Proxy->>Client: 10. Redirect to localhost:54321/callback<br/>with new authorization code - - Note over Client, Proxy: Token Exchange - Client->>Proxy: 11. POST /token with code<br/>code_verifier=CLIENT_VERIFIER - Proxy-->>Client: 12. Returns FastMCP JWT tokens -``` - -The flow diagram above illustrates the complete OAuth proxy pattern. Let's understand each phase: - -### Registration Phase - -When an MCP client calls `/register` with its dynamic callback URL, the proxy responds with your pre-configured upstream credentials. The client stores these credentials believing it has registered a new app. Meanwhile, the proxy records the client's callback URL for later use. - -### Authorization Phase - -The client initiates OAuth by redirecting to the proxy's `/authorize` endpoint. The proxy: - -1. Stores the client's transaction with its PKCE challenge -2. Generates its own PKCE parameters for upstream security -3. Shows the user a consent page with the client's details, redirect URI, and requested scopes -4. If the user approves (or the client was previously approved), sets a consent binding cookie and redirects to the upstream provider using the fixed callback URL - -This dual-PKCE approach maintains end-to-end security at both the client-to-proxy and proxy-to-provider layers. The consent step protects against confused deputy attacks by ensuring you explicitly approve each client before it can complete authorization, and the consent binding cookie ensures that only the browser that approved consent can complete the callback. - -### Callback Phase - -After user authorization, the provider redirects back to the proxy's fixed callback URL. The proxy: - -1. Verifies the consent binding cookie matches the transaction (rejecting requests from a different browser) -2. Exchanges the authorization code for tokens with the provider -3. Stores these tokens temporarily -4. Generates a new authorization code for the client -5. Redirects to the client's original dynamic callback URL - -### Token Exchange Phase - -Finally, the client exchanges its authorization code with the proxy. The proxy validates the client's PKCE verifier, then issues its own FastMCP JWT tokens (rather than forwarding the upstream provider's tokens). See [Token Architecture](#token-architecture) for details on this design. - -This entire flow is transparent to the MCP client—it experiences a standard OAuth flow with dynamic registration, unaware that a proxy is managing the complexity behind the scenes. - -### Token Architecture - -The OAuth proxy implements a **token factory pattern**: instead of directly forwarding tokens from the upstream OAuth provider, it issues its own JWT tokens to MCP clients. This maintains proper OAuth 2.0 token audience boundaries and enables better security controls. - -**How it works:** - -When an MCP client completes authorization, the proxy: - -1. **Receives upstream tokens** from the OAuth provider (GitHub, Google, etc.) -2. **Encrypts and stores** these tokens using Fernet encryption (AES-128-CBC + HMAC-SHA256) -3. **Issues FastMCP JWT tokens** to the client, signed with HS256 - -The FastMCP JWT contains minimal claims: issuer, audience, client ID, scopes, expiration, and a unique token identifier (JTI). The JTI acts as a reference linking to the encrypted upstream token. - -**Token validation:** - -When a client makes an MCP request with its FastMCP token: - -1. **FastMCP validates the JWT** signature, expiration, issuer, and audience -2. **Looks up the upstream token** using the JTI from the validated JWT -3. **Decrypts and validates** the upstream token with the provider - -This two-tier validation ensures that FastMCP tokens can only be used with this server (via audience validation) while maintaining full upstream token security. - -This architecture also prevents [token passthrough](#token-passthrough) — see the [Security](#security) section for details. - -**Token expiry alignment:** - -By default, FastMCP token lifetimes match the upstream token lifetimes. When the upstream token expires, the FastMCP token also expires, maintaining consistent security boundaries. - -**Extending the FastMCP token lifetime:** - -Some upstream providers issue short-lived access tokens (5–60 minutes is common). Because the FastMCP token is a reference into the proxy's storage rather than the upstream credential itself, its client-facing lifetime can be longer than the upstream token's without weakening security: every request re-validates the upstream token and transparently refreshes it when it has expired, so a revoked or genuinely expired upstream session still fails validation and forces re-authentication. - -This matters for MCP clients that don't refresh gracefully. For example, [`mcp-remote`](https://github.com/geelen/mcp-remote) (used by Claude Desktop) has known issues handling access-token expiry, so a short upstream lifetime can push users through a full OAuth flow after every idle period. Set `fastmcp_access_token_expiry_seconds` to decouple the FastMCP token lifetime from the upstream `expires_in`: - -```python -from fastmcp.server.auth import OAuthProxy - -auth = OAuthProxy( - upstream_authorization_endpoint="https://provider.com/oauth/authorize", - upstream_token_endpoint="https://provider.com/oauth/token", - upstream_client_id="your-client-id", - upstream_client_secret="your-client-secret", - token_verifier=token_verifier, - base_url="https://your-server.com", - fastmcp_access_token_expiry_seconds=60 * 60 * 24, # 24 hours -) -``` - -The upstream token's real expiry is preserved internally to drive transparent refresh; only the FastMCP-issued token lives longer. This parameter is available on every provider built on the OAuth proxy (`GitHubProvider`, `GoogleProvider`, `AzureProvider`, and the rest). - -Extending the lifetime only works when the upstream provider issues a refresh token, since that's what lets the proxy renew the access token behind the scenes. When the upstream provides no refresh token, the FastMCP token lifetime is capped at the upstream `expires_in` — issuing a longer-lived token would claim a validity the proxy can't honor. - -**Refresh tokens:** - -The proxy issues its own refresh tokens that map to upstream refresh tokens. When a client uses a FastMCP refresh token, the proxy refreshes the upstream token and issues a new FastMCP access token. - -### PKCE Forwarding - -The OAuth proxy automatically handles PKCE (Proof Key for Code Exchange) when working with providers that support or require it. The proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE, ensuring end-to-end security at both layers. - -This is enabled by default via the `forward_pkce` parameter and works seamlessly with providers like Google, Azure AD, and GitHub. Only disable it for legacy providers that don't support PKCE: - -```python -# Disable PKCE forwarding only if upstream doesn't support it -auth = OAuthProxy( - ..., - forward_pkce=False # Default is True -) -``` - -### Redirect URI Validation - -By default, the OAuth proxy validates DCR clients against their registered redirect URIs while allowing loopback ports to vary for MCP compatibility. Unsafe browser schemes such as `javascript:` are always rejected. You can restrict which clients can connect at the server level by specifying allowed patterns: - -```python -# Allow only localhost clients (common for development) -auth = OAuthProxy( - # ... other parameters ... - allowed_client_redirect_uris=[ - "http://localhost:*", - "http://127.0.0.1:*" - ] -) - -# Allow specific known clients -auth = OAuthProxy( - # ... other parameters ... - allowed_client_redirect_uris=[ - "http://localhost:*", - "https://claude.ai/api/mcp/auth_callback", - "https://*.mycompany.com/auth/*" # Wildcard patterns supported - ] -) -``` - -Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use. - -## CIMD Support - -<VersionBadge version="3.0.0" /> - -The OAuth proxy supports **Client ID Metadata Documents (CIMD)**, an alternative to Dynamic Client Registration where clients host a static JSON document at an HTTPS URL. Instead of registering dynamically, clients simply provide their CIMD URL as their `client_id`, and the server fetches and validates the metadata. - -CIMD clients appear in the consent screen with a verified domain badge, giving users confidence about which application is requesting access. This provides stronger identity verification than DCR, where any client can claim any name. - -### How CIMD Works - -When a client presents an HTTPS URL as its `client_id` (for example, `https://myapp.example.com/oauth/client.json`), the OAuth proxy recognizes it as a CIMD client and: - -1. Fetches the JSON document from that URL -2. Validates that the document's `client_id` field matches the URL -3. Extracts client metadata (name, redirect URIs, scopes, etc.) -4. Stores the client persistently alongside DCR clients -5. Shows the verified domain in the consent screen - -This flow happens transparently. MCP clients that support CIMD simply provide their metadata URL instead of registering, and the OAuth proxy handles the rest. - -### CIMD Configuration - -CIMD support is enabled by default for `OAuthProxy`. - -<Card icon="code" title="CIMD Parameters"> -<ParamField body="enable_cimd" type="bool" default="True"> - Whether to accept CIMD URLs as client identifiers. When enabled, clients can use HTTPS URLs pointing to metadata documents as their `client_id` instead of registering via DCR. -</ParamField> -</Card> - -### Private Key JWT Authentication - -CIMD clients can authenticate using `private_key_jwt` instead of the default `none` authentication method. This provides cryptographic proof of client identity by signing JWT assertions with a private key, while the server verifies using the client's public key from their CIMD document. - -To use `private_key_jwt`, the CIMD document must include either a `jwks_uri` (URL to fetch the public key set) or inline `jwks` (the key set directly in the document): - -```json -{ - "client_id": "https://myapp.example.com/oauth/client.json", - "client_name": "My Secure App", - "redirect_uris": ["http://localhost:*/callback"], - "token_endpoint_auth_method": "private_key_jwt", - "jwks_uri": "https://myapp.example.com/.well-known/jwks.json" -} -``` - -The OAuth proxy validates JWT assertions according to RFC 7523, checking the signature, issuer, audience, subject claims, and preventing replay attacks via JTI tracking. - -### Security Considerations - -CIMD provides several security advantages over DCR: - -- **Verified identity**: The domain in the `client_id` URL is verified by HTTPS, so users know which organization is requesting access -- **No registration required**: Clients don't need to store or manage dynamically-issued credentials -- **Redirect URI enforcement**: CIMD documents must declare `redirect_uris`, which are enforced by the proxy (wildcard patterns supported) -- **SSRF protection**: The OAuth proxy blocks fetches to localhost, private IPs, and reserved addresses -- **Replay prevention**: For `private_key_jwt` clients, JTI claims are tracked to prevent assertion replay -- **Cache-aware fetching**: CIMD documents are cached according to HTTP cache headers and revalidated when required - -CIMD is enabled by default. To disable it entirely (for example, to require all clients to register via DCR), set `enable_cimd=False` explicitly: - -```python -auth = OAuthProxy( - ..., - enable_cimd=False, -) -``` - -## Security - -### Key and Storage Management - -<VersionBadge version="2.13.0" /> -The OAuth proxy requires cryptographic keys for JWT signing and storage encryption, plus persistent storage to maintain valid tokens across server restarts. - -**Default behavior (appropriate for development only):** -- **Mac/Windows**: FastMCP automatically generates keys and stores them in your system keyring. Storage defaults to disk. Tokens survive server restarts. This is **only** suitable for development and local testing. -- **Linux**: Keys are ephemeral (random salt at startup). Storage defaults to memory. Tokens become invalid on server restart. - -**For production:** -Configure the following parameters together: provide a unique `jwt_signing_key` (for signing FastMCP JWTs), and a shared `client_storage` backend (for storing tokens). Both are required for production deployments. Use a network-accessible storage backend like Redis or DynamoDB rather than local disk storage. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** (see the `client_storage` parameter documentation above for examples). The keys accept any secret string and derive proper cryptographic keys using HKDF. See [OAuth Token Security](/deployment/http#oauth-token-security) and [Storage Backends](/servers/storage-backends) for complete production setup. - -### Confused Deputy Attacks - -<VersionBadge version="2.13.0" /> - -A confused deputy attack allows a malicious client to steal your authorization by tricking you into granting it access under your identity. - -The OAuth proxy works by bridging DCR clients to traditional auth providers, which means that multiple MCP clients connect through a single upstream OAuth application. An attacker can exploit this shared application by registering a malicious client with their own redirect URI, then sending you an authorization link. When you click it, your browser goes through the OAuth flow—but since you may have already authorized this OAuth app before, the provider might auto-approve the request. The authorization code then gets sent to the attacker's redirect URI instead of a legitimate client, giving them access under your credentials. - -#### Mitigation - -FastMCP's OAuth proxy defends against confused deputy attacks with two layers of protection: - -**Consent screen.** Before any authorization happens, you see a consent page showing the client's details, redirect URI, and requested scopes. This gives you the opportunity to review and deny suspicious requests. By default (`require_authorization_consent=True`), the page is shown on every flow, which is the strongest protection. Setting `require_authorization_consent="remember"` approves previously-approved `(client_id, redirect_uri)` pairs silently on return visits, trading some protection for UX (see below). The consent mechanism is implemented with CSRF tokens and cryptographically signed cookies to prevent tampering. - -![](/assets/images/oauth-proxy-consent-screen.png) - -The consent page automatically displays your server's name, icon, and website URL, if available. These visual identifiers help users confirm they're authorizing the correct server. - -**Browser-session binding.** When you approve consent (or when a previously-approved client auto-approves), the proxy sets a cryptographically signed cookie that binds your browser session to the authorization flow. When the identity provider redirects back to the proxy's callback, the proxy verifies that this cookie is present and matches the expected transaction. A different browser — such as a victim who was sent the authorization URL by an attacker — won't have this cookie, and the callback will be rejected with a 403 error. This prevents the attack even when the identity provider skips the consent page for previously-authorized applications. - -#### AS-in-the-middle variant - -A related attack works even with browser-session binding in place: a malicious MCP server advertises its own authorization server, which redirects the victim's browser into the legitimate proxy's `/authorize` endpoint. Because the victim's browser carries both the prior-approval cookie and the newly-issued session-binding cookie throughout, both layers pass. The defense is the consent prompt itself: if consent is shown (`require_authorization_consent=True`), the victim sees the benign MCP server's name on the consent page — which doesn't match the malicious server they thought they were connecting to — and can deny. - -`require_authorization_consent="remember"` adds a `Sec-Fetch-Site` check to keep this path safe for legitimate return flows (the attack navigation lands as `cross-site` and falls back to the prompt), but this is a browser-level heuristic. For the strongest defense, leave `require_authorization_consent=True`. - -**Learn more:** -- [MCP Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#confused-deputy-problem) - Official specification guidance -- [Confused Deputy Attacks Explained](https://den.dev/blog/mcp-confused-deputy-api-management/) - Detailed walkthrough by Den Delimarsky - -### Token Passthrough - -[Token passthrough](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#token-passthrough) occurs when an intermediary exposes upstream tokens to downstream clients, allowing those clients to impersonate the intermediary or access services they shouldn't reach. - -#### Client-facing mitigation - -The OAuth proxy's [token factory architecture](#token-architecture) prevents this by design. MCP clients only ever receive FastMCP-issued JWTs — the upstream provider token is never sent to the client. A FastMCP JWT is scoped to your server and cannot be used to access the upstream provider directly, even if intercepted. - -#### Calling downstream services - -When your MCP server needs to call other APIs on behalf of the authenticated user, avoid forwarding the upstream token directly — this reintroduces the token passthrough problem in the other direction. Instead, use a token exchange flow like [OAuth 2.0 Token Exchange (RFC 8693)](https://datatracker.ietf.org/doc/html/rfc8693) or your provider's equivalent (such as Azure's [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow)) to obtain a new token scoped to the downstream service. - -The upstream token is available in your tool functions via `get_access_token()` or the `CurrentAccessToken` dependency, which you can use as the assertion for a token exchange. The exchanged token will be scoped to the specific downstream service and identify your MCP server as the authorized intermediary, maintaining proper audience boundaries throughout the chain. - -## Production Configuration - -For production deployments, load sensitive credentials from environment variables: - -```python -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider - -# Load secrets from environment variables -auth = GitHubProvider( - client_id=os.environ.get("GITHUB_CLIENT_ID"), - client_secret=os.environ.get("GITHUB_CLIENT_SECRET"), - base_url=os.environ.get("BASE_URL", "https://your-production-server.com") -) - -mcp = FastMCP(name="My Server", auth=auth) - -@mcp.tool -def protected_tool(data: str) -> str: - """This tool is now protected by OAuth.""" - return f"Processed: {data}" - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -This keeps secrets out of your codebase while maintaining explicit configuration. diff --git a/docs/v3/servers/auth/oidc-proxy.mdx b/docs/v3/servers/auth/oidc-proxy.mdx deleted file mode 100644 index 81ca677a2..000000000 --- a/docs/v3/servers/auth/oidc-proxy.mdx +++ /dev/null @@ -1,287 +0,0 @@ ---- -title: OIDC Proxy -sidebarTitle: OIDC Proxy -description: Bridge OIDC providers to work seamlessly with MCP's authentication flow. -icon: share ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<VersionBadge version="2.12.4" /> - -The OIDC proxy enables FastMCP servers to authenticate with OIDC providers that **don't support Dynamic Client Registration (DCR)** out of the box. This includes OAuth providers like: Auth0, Google, Azure, AWS, etc. For providers that do support DCR (like WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. - -The OIDC proxy is built upon [`OAuthProxy`](/servers/auth/oauth-proxy) so it has all the same functionality under the covers. - -## Implementation - -### Provider Setup Requirements - -Before using the OIDC proxy, you need to register your application with your OAuth provider: - -1. **Register your application** in the provider's developer console (Auth0 Applications, Google Cloud Console, Azure Portal, etc.) -2. **Configure the redirect URI** as your FastMCP server URL plus your chosen callback path: - - Default: `https://your-server.com/auth/callback` - - Custom: `https://your-server.com/your/custom/path` (if you set `redirect_path`) - - Development: `http://localhost:8000/auth/callback` -3. **Obtain your credentials**: Client ID and Client Secret - -<Warning> - The redirect URI you configure with your provider must exactly match your - FastMCP server's URL plus the callback path. If you customize `redirect_path` - in the OIDC proxy, update your provider's redirect URI accordingly. -</Warning> - -### Basic Setup - -Here's how to implement the OIDC proxy with any provider: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.oidc_proxy import OIDCProxy - -# Create the OIDC proxy -auth = OIDCProxy( - # Provider's configuration URL - config_url="https://provider.com/.well-known/openid-configuration", - - # Your registered app credentials - client_id="your-client-id", - client_secret="your-client-secret", - - # Your FastMCP server's public URL - base_url="https://your-server.com", - - # Optional: customize the callback path (default is "/auth/callback") - # redirect_path="/custom/callback", -) - -mcp = FastMCP(name="My Server", auth=auth) -``` - -### Configuration Parameters - -<Card icon="code" title="OIDCProxy Parameters"> -<ParamField body="config_url" type="str" required> - URL of your OAuth provider's OIDC configuration -</ParamField> - -<ParamField body="client_id" type="str" required> - Client ID from your registered OAuth application -</ParamField> - -<ParamField body="client_secret" type="str | None"> - Client secret from your registered OAuth application. Optional for PKCE public - clients. When omitted, `jwt_signing_key` must be provided. -</ParamField> - -<ParamField body="base_url" type="AnyHttpUrl | str" required> - Public URL of your FastMCP server (e.g., `https://your-server.com`) -</ParamField> - -<ParamField body="resource_base_url" type="AnyHttpUrl | str | None"> - Optional public base URL for the protected resource metadata and token audience. - - Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL. -</ParamField> - -<ParamField body="strict" type="bool | None"> - Strict flag for configuration validation. When True, requires all OIDC - mandatory fields. -</ParamField> - -<ParamField body="audience" type="str | None"> - Audience parameter for OIDC providers that require it (e.g., Auth0). This is - typically your API identifier. -</ParamField> - -<ParamField body="timeout_seconds" type="int | None" default="10"> - HTTP request timeout in seconds for fetching OIDC configuration -</ParamField> - -<ParamField body="token_verifier" type="TokenVerifier | None"> - -<VersionBadge version="2.13.1" /> - Custom token verifier for validating tokens. When provided, FastMCP uses your custom verifier instead of creating a default `JWTVerifier`. - - Cannot be used with `algorithm` or `required_scopes` parameters - configure these on your verifier instead. The verifier's `required_scopes` are automatically loaded and advertised. -</ParamField> - -<ParamField body="algorithm" type="str | None"> - JWT algorithm to use for token verification (e.g., "RS256"). If not specified, - uses the provider's default. Only used when `token_verifier` is not provided. -</ParamField> - -<ParamField body="required_scopes" type="list[str] | None"> - List of OAuth scopes for token validation. These are automatically - included in authorization requests. Only used when `token_verifier` is not provided. -</ParamField> - -<ParamField body="redirect_path" type="str" default="/auth/callback"> - Path for OAuth callbacks. Must match the redirect URI configured in your OAuth - application -</ParamField> - -<ParamField body="allowed_client_redirect_uris" type="list[str] | None"> - List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`). - - `None` (default): DCR clients use registered redirect URIs, with loopback ports allowed to vary for MCP compatibility. Unsafe browser schemes such as `javascript:`, `data:`, `file:`, and `vbscript:` are rejected. - - Empty list `[]`: No redirect URIs allowed - - Custom list: Only matching patterns allowed - -These patterns apply to MCP client loopback redirects. Configure the upstream OAuth app redirect URI separately with `redirect_path`. - -</ParamField> - -<ParamField body="token_endpoint_auth_method" type="str | None"> - Token endpoint authentication method for the upstream OAuth server. Controls how the proxy authenticates when exchanging authorization codes and refresh tokens with the upstream provider. - - `"client_secret_basic"`: Send credentials in Authorization header (most common) - - `"client_secret_post"`: Send credentials in request body (required by some providers) - - `"none"`: No authentication (for public clients) - - `None` (default): Uses authlib's default (typically `"client_secret_basic"`) - -Set this if your provider requires a specific authentication method and the default doesn't work. - -</ParamField> - -<ParamField body="jwt_signing_key" type="str | bytes | None"> - -<VersionBadge version="2.13.0" /> - Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF. - - **Default behavior (`None`):** - - **Mac/Windows**: Auto-managed via system keyring. Keys are generated once and persisted, surviving server restarts with zero configuration. Keys are automatically derived from server attributes, so this approach, while convenient, is **only** suitable for development and local testing. For production, you must provide an explicit secret. - - **Linux**: Ephemeral (random salt at startup). Tokens become invalid on server restart, triggering client re-authentication. - - **For production:** - Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the auto-generated one. -</ParamField> - -<ParamField body="client_storage" type="AsyncKeyValue | None"> - -<VersionBadge version="2.13.0" /> - Storage backend for persisting OAuth client registrations and upstream tokens. - - **Default behavior:** - - **Mac/Windows**: Encrypted DiskStore in your platform's data directory (derived from `platformdirs`) - - **Linux**: MemoryStore (ephemeral - clients lost on restart) - - By default on Mac/Windows, clients are automatically persisted to encrypted disk storage, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly. On Linux where keyring isn't available, ephemeral storage is used to match the ephemeral key strategy. - -For production deployments with multiple servers or cloud deployments, use a network-accessible storage backend rather than local disk storage. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest.** See [Storage Backends](/servers/storage-backends) for available options. - -Testing with in-memory storage (unencrypted): - -```python -from key_value.aio.stores.memory import MemoryStore - -# Use in-memory storage for testing (clients lost on restart) -auth = OIDCProxy(..., client_storage=MemoryStore()) -``` - -Production with encrypted Redis storage: - -```python -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet -import os - -auth = OIDCProxy( - ..., - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=FernetEncryptionWrapper( - key_value=RedisStore(host="redis.example.com", port=6379), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) -``` - -</ParamField> - -<ParamField body="require_authorization_consent" type='bool | Literal["remember", "external"]' default="True"> - Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs. -</ParamField> - -<ParamField body="consent_csp_policy" type="str | None" default="None"> - Content Security Policy for the consent page. - - - `None` (default): Uses the built-in CSP policy with appropriate directives for form submission - - Empty string `""`: Disables CSP entirely (no meta tag rendered) - - Custom string: Uses the provided value as the CSP policy - - This is useful for organizations that have their own CSP policies and need to override or disable FastMCP's built-in CSP directives. -</ParamField> -</Card> - -### Using Built-in Providers - -FastMCP includes pre-configured OIDC providers for common services: - -```python -from fastmcp.server.auth.providers.auth0 import Auth0Provider - -auth = Auth0Provider( - config_url="https://.../.well-known/openid-configuration", - client_id="your-auth0-client-id", - client_secret="your-auth0-client-secret", - audience="https://...", - base_url="https://localhost:8000" -) - -mcp = FastMCP(name="My Server", auth=auth) -``` - -Available providers include `Auth0Provider` at present. - -### Scope Configuration - -OAuth scopes are configured with `required_scopes` to automatically request the permissions your application needs. - -Dynamic clients created by the proxy will automatically include these scopes in their authorization requests. - -## CIMD Support - -<VersionBadge version="3.0.0" /> - -The OIDC proxy inherits full CIMD (Client ID Metadata Document) support from `OAuthProxy`. Clients can use HTTPS URLs as their `client_id` instead of registering dynamically, and the proxy will fetch and validate their metadata document. - -See the [OAuth Proxy CIMD documentation](/servers/auth/oauth-proxy#cimd-support) for complete details on how CIMD works, including private key JWT authentication and security considerations. - -The CIMD-related parameters available on `OIDCProxy` are: - -<Card icon="code" title="CIMD Parameters"> -<ParamField body="enable_cimd" type="bool" default="True"> - Whether to accept CIMD URLs as client identifiers. -</ParamField> -</Card> - -## Production Configuration - -For production deployments, load sensitive credentials from environment variables: - -```python -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider - -# Load secrets from environment variables -auth = Auth0Provider( - config_url=os.environ.get("AUTH0_CONFIG_URL"), - client_id=os.environ.get("AUTH0_CLIENT_ID"), - client_secret=os.environ.get("AUTH0_CLIENT_SECRET"), - audience=os.environ.get("AUTH0_AUDIENCE"), - base_url=os.environ.get("BASE_URL", "https://localhost:8000") -) - -mcp = FastMCP(name="My Server", auth=auth) - -@mcp.tool -def protected_tool(data: str) -> str: - """This tool is now protected by OAuth.""" - return f"Processed: {data}" - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -This keeps secrets out of your codebase while maintaining explicit configuration. diff --git a/docs/v3/servers/auth/remote-oauth.mdx b/docs/v3/servers/auth/remote-oauth.mdx deleted file mode 100644 index c2256b052..000000000 --- a/docs/v3/servers/auth/remote-oauth.mdx +++ /dev/null @@ -1,240 +0,0 @@ ---- -title: Remote OAuth -sidebarTitle: Remote OAuth -description: Integrate your FastMCP server with external identity providers like Descope, WorkOS, Auth0, and corporate SSO systems. -icon: camera-cctv ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.11.0" /> - -Remote OAuth integration allows your FastMCP server to leverage external identity providers that **support Dynamic Client Registration (DCR)**. With DCR, MCP clients can automatically register themselves with the identity provider and obtain credentials without any manual configuration. This provides enterprise-grade authentication with fully automated flows, making it ideal for production applications with modern identity providers. - -<Tip> -**When to use RemoteAuthProvider vs OAuth Proxy:** -- **RemoteAuthProvider**: For providers WITH Dynamic Client Registration (Descope, WorkOS AuthKit, modern OIDC providers) -- **OAuth Proxy**: For providers WITHOUT Dynamic Client Registration (GitHub, Google, Azure, AWS, Discord, etc.) - -RemoteAuthProvider requires DCR support for fully automated client registration and authentication. -</Tip> - -## DCR-Enabled Providers - -RemoteAuthProvider works with identity providers that support **Dynamic Client Registration (DCR)** - a critical capability that enables automated authentication flows: - -| Feature | DCR Providers (RemoteAuth) | Non-DCR Providers (OAuth Proxy) | -|---------|---------------------------|--------------------------------| -| **Client Registration** | Automatic via API | Manual in provider console | -| **Credentials** | Dynamic per client | Fixed app credentials | -| **Configuration** | Zero client config | Pre-shared credentials | -| **Examples** | Descope, WorkOS AuthKit, modern OIDC | GitHub, Google, Azure | -| **FastMCP Class** | `RemoteAuthProvider` | [`OAuthProxy`](/servers/auth/oauth-proxy) | - -If your provider doesn't support DCR (most traditional OAuth providers), you'll need to use [`OAuth Proxy`](/servers/auth/oauth-proxy) instead, which bridges the gap between MCP's DCR expectations and fixed OAuth credentials. - -## The Remote OAuth Challenge - -Traditional OAuth flows assume human users with web browsers who can interact with login forms, consent screens, and redirects. MCP clients operate differently - they're often automated systems that need to authenticate programmatically without human intervention. - -This creates several unique requirements that standard OAuth implementations don't address well: - -**Automatic Discovery**: MCP clients must discover authentication requirements by examining server metadata rather than encountering HTTP redirects. They need to know which identity provider to use and how to reach it before making any authenticated requests. - -**Programmatic Registration**: Clients need to register themselves with identity providers automatically. Manual client registration doesn't work when clients might be dynamically created tools or services. - -**Seamless Token Management**: Clients must obtain, store, and refresh tokens without user interaction. The authentication flow needs to work in headless environments where no human is available to complete OAuth consent flows. - -**Protocol Integration**: The authentication process must integrate cleanly with MCP's JSON-RPC transport layer and error handling mechanisms. - -These requirements mean that your MCP server needs to do more than just validate tokens - it needs to provide discovery metadata that enables MCP clients to understand and navigate your authentication requirements automatically. - -## MCP Authentication Discovery - -MCP authentication discovery relies on well-known endpoints that clients can examine to understand your authentication requirements. Your server becomes a bridge between MCP clients and your chosen identity provider. - -The core discovery endpoint is `/.well-known/oauth-protected-resource`, which tells clients that your server requires OAuth authentication and identifies the authorization servers you trust. This endpoint contains static metadata that points clients to your identity provider without requiring any dynamic lookups. - -```mermaid -sequenceDiagram - participant Client - participant FastMCPServer as FastMCP Server - participant ExternalIdP as Identity Provider - - Client->>FastMCPServer: 1. GET /.well-known/oauth-protected-resource - FastMCPServer-->>Client: 2. "Use https://my-idp.com for auth" - - note over Client, ExternalIdP: Client goes directly to the IdP - Client->>ExternalIdP: 3. Authenticate & get token via DCR - ExternalIdP-->>Client: 4. Access token - - Client->>FastMCPServer: 5. MCP request with Bearer token - FastMCPServer->>FastMCPServer: 6. Verify token signature - FastMCPServer-->>Client: 7. MCP response -``` - -This flow separates concerns cleanly: your MCP server handles resource protection and token validation, while your identity provider handles user authentication and token issuance. The client coordinates between these systems using standardized OAuth discovery mechanisms. - -## FastMCP Remote Authentication - -<VersionBadge version="2.11.1" /> - -FastMCP provides `RemoteAuthProvider` to handle the complexities of remote OAuth integration. This class combines token validation capabilities with the OAuth discovery metadata that MCP clients require. - -### RemoteAuthProvider - -`RemoteAuthProvider` works by composing a [`TokenVerifier`](/servers/auth/token-verification) with authorization server information. A `TokenVerifier` is another FastMCP authentication class that focuses solely on token validation - signature verification, expiration checking, and claim extraction. The `RemoteAuthProvider` takes that token validation capability and adds the OAuth discovery endpoints that enable MCP clients to automatically find and authenticate with your identity provider. - -This composition pattern means you can use any token validation strategy while maintaining consistent OAuth discovery behavior: -- **JWT tokens**: Use `JWTVerifier` for self-contained tokens -- **Opaque tokens**: Use `IntrospectionTokenVerifier` for RFC 7662 introspection -- **Custom validation**: Implement your own `TokenVerifier` subclass - -The separation allows you to change token validation approaches without affecting the client discovery experience. - -The class automatically generates the required OAuth metadata endpoints using the MCP SDK's standardized route creation functions. This ensures compatibility with MCP clients while reducing the implementation complexity for server developers. - -### Basic Implementation - -Most applications can use `RemoteAuthProvider` directly without subclassing. The implementation requires a `TokenVerifier` instance, a list of trusted authorization servers, and your server's URL for metadata generation. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import RemoteAuthProvider -from fastmcp.server.auth.providers.jwt import JWTVerifier -from pydantic import AnyHttpUrl - -# Configure token validation for your identity provider -token_verifier = JWTVerifier( - jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", - issuer="https://auth.yourcompany.com", - audience="mcp-production-api" -) - -# Create the remote auth provider -auth = RemoteAuthProvider( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")], - base_url="https://api.yourcompany.com", # Your server base URL - # Optional: restrict allowed client redirect URIs - allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"] -) - -mcp = FastMCP(name="Company API", auth=auth) -``` - -This configuration creates a server that accepts tokens issued by `auth.yourcompany.com` and provides the OAuth discovery metadata that MCP clients need. The `JWTVerifier` handles token validation using your identity provider's public keys, while the `RemoteAuthProvider` generates the required OAuth endpoints. - -The `authorization_servers` list tells MCP clients which identity providers you trust. The `base_url` identifies your server in OAuth metadata, enabling proper token audience validation. **Important**: The `base_url` should point to your server base URL - for example, if your MCP server is accessible at `https://api.yourcompany.com/mcp`, use `https://api.yourcompany.com` as the base URL. - -### Overriding Advertised Scopes - -Some identity providers use different scope formats for authorization requests versus token claims. For example, Azure AD requires clients to request full URI scopes like `api://client-id/read`, but the token's `scp` claim contains just `read`. The `scopes_supported` parameter lets you advertise the full-form scopes in metadata while validating against the short form: - -```python -auth = RemoteAuthProvider( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - base_url="https://api.example.com", - scopes_supported=["api://my-api/read", "api://my-api/write"], -) -``` - -When not set, `scopes_supported` defaults to the token verifier's `required_scopes`. For Azure AD specifically, see the [AzureJWTVerifier](/integrations/azure#token-verification-only-managed-identity) which handles this automatically. - -### Custom Endpoints - -You can extend `RemoteAuthProvider` to add additional endpoints beyond the standard OAuth protected resource metadata. These don't have to be OAuth-specific - you can add any endpoints your authentication integration requires. - -```python -import httpx -from starlette.responses import JSONResponse -from starlette.routing import Route - -class CompanyAuthProvider(RemoteAuthProvider): - def __init__(self): - token_verifier = JWTVerifier( - jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", - issuer="https://auth.yourcompany.com", - audience="mcp-production-api" - ) - - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")], - base_url="https://api.yourcompany.com" # Your server base URL - ) - - def get_routes(self) -> list[Route]: - """Add custom endpoints to the standard protected resource routes.""" - - # Get the standard OAuth protected resource routes - routes = super().get_routes() - - # Add authorization server metadata forwarding for client convenience - async def authorization_server_metadata(request): - async with httpx.AsyncClient() as client: - response = await client.get( - "https://auth.yourcompany.com/.well-known/oauth-authorization-server" - ) - response.raise_for_status() - return JSONResponse(response.json()) - - routes.append( - Route("/.well-known/oauth-authorization-server", authorization_server_metadata) - ) - - return routes - -mcp = FastMCP(name="Company API", auth=CompanyAuthProvider()) -``` - -This pattern uses `super().get_routes()` to get the standard protected resource routes, then adds additional endpoints as needed. A common use case is providing authorization server metadata forwarding, which allows MCP clients to discover your identity provider's capabilities through your MCP server rather than contacting the identity provider directly. - -## WorkOS AuthKit Integration - -WorkOS AuthKit provides an excellent example of remote OAuth integration. The `AuthKitProvider` demonstrates how to implement both token validation and OAuth metadata forwarding in a production-ready package. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider - -auth = AuthKitProvider( - authkit_domain="https://your-project.authkit.app", - base_url="https://your-mcp-server.com" -) - -mcp = FastMCP(name="Protected Application", auth=auth) -``` - -The `AuthKitProvider` automatically configures JWT validation against WorkOS's public keys and provides both protected resource metadata and authorization server metadata forwarding. This implementation handles the complete remote OAuth integration with minimal configuration. - -WorkOS's support for Dynamic Client Registration makes it particularly well-suited for MCP applications. Clients can automatically register themselves with your WorkOS project and obtain the credentials needed for authentication without manual intervention. - -→ **Complete WorkOS tutorial**: [AuthKit Integration Guide](/integrations/authkit) - -## Client Redirect URI Security - -<Note> -`RemoteAuthProvider` also supports the `allowed_client_redirect_uris` parameter for controlling which redirect URIs are accepted from MCP clients during DCR: - -- `None` (default): Broad DCR-compatible redirect support, while rejecting unsafe browser schemes such as `javascript:`, `data:`, `file:`, and `vbscript:` -- Custom list: Specify allowed patterns with wildcard support -- Empty list `[]`: No redirect URIs allowed - -This provides defense-in-depth even though DCR providers typically validate redirect URIs themselves. -</Note> - -## Implementation Considerations - -Remote OAuth integration requires careful attention to several technical details that affect reliability and security. - -**Token Validation Performance**: Your server validates every incoming token by checking signatures against your identity provider's public keys. Consider implementing key caching and rotation handling to minimize latency while maintaining security. - -**Error Handling**: Network issues with your identity provider can affect token validation. Implement appropriate timeouts, retry logic, and graceful degradation to maintain service availability during identity provider outages. - -**Audience Validation**: Ensure that tokens intended for your server are not accepted by other applications. Proper audience validation prevents token misuse across different services in your ecosystem. - -**Scope Management**: Map token scopes to your application's permission model consistently. Consider how scope changes affect existing tokens and plan for smooth permission updates. - -The complexity of these considerations reinforces why external identity providers are recommended over custom OAuth implementations. Established providers handle these technical details with extensive testing and operational experience. diff --git a/docs/v3/servers/auth/token-verification.mdx b/docs/v3/servers/auth/token-verification.mdx deleted file mode 100644 index a9146135f..000000000 --- a/docs/v3/servers/auth/token-verification.mdx +++ /dev/null @@ -1,426 +0,0 @@ ---- -title: Token Verification -sidebarTitle: Token Verification -description: Protect your server by validating bearer tokens issued by external systems. -icon: key ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.11.0" /> - -Token verification enables your FastMCP server to validate bearer tokens issued by external systems without participating in user authentication flows. Your server acts as a pure resource server, focusing on token validation and authorization decisions while delegating identity management to other systems in your infrastructure. - -<Note> -Token verification operates somewhat outside the formal MCP authentication flow, which expects OAuth-style discovery. It's best suited for internal systems, microservices architectures, or when you have full control over token generation and distribution. -</Note> - -## Understanding Token Verification - -Token verification addresses scenarios where authentication responsibility is distributed across multiple systems. Your MCP server receives structured tokens containing identity and authorization information, validates their authenticity, and makes access control decisions based on their contents. - -This pattern emerges naturally in microservices architectures where a central authentication service issues tokens that multiple downstream services validate independently. It also works well when integrating MCP servers into existing systems that already have established token-based authentication mechanisms. - -### The Token Verification Model - -Token verification treats your MCP server as a resource server in OAuth terminology. The key insight is that token validation and token issuance are separate concerns that can be handled by different systems. - -**Token Issuance**: Another system (API gateway, authentication service, or identity provider) handles user authentication and creates signed tokens containing identity and permission information. - -**Token Validation**: Your MCP server receives these tokens, verifies their authenticity using cryptographic signatures, and extracts authorization information from their claims. - -**Access Control**: Based on token contents, your server determines what resources, tools, and prompts the client can access. - -This separation allows your MCP server to focus on its core functionality while leveraging existing authentication infrastructure. The token acts as a portable proof of identity that travels with each request. - -### Token Security Considerations - -Token-based authentication relies on cryptographic signatures to ensure token integrity. Your MCP server validates tokens using public keys corresponding to the private keys used for token creation. This asymmetric approach means your server never needs access to signing secrets. - -Token validation must address several security requirements: signature verification ensures tokens haven't been tampered with, expiration checking prevents use of stale tokens, and audience validation ensures tokens intended for your server aren't accepted by other systems. - -The challenge in MCP environments is that clients need to obtain valid tokens before making requests, but the MCP protocol doesn't provide built-in discovery mechanisms for token endpoints. Clients must obtain tokens through separate channels or prior configuration. - - -## TokenVerifier Class - -FastMCP provides the `TokenVerifier` class to handle token validation complexity while remaining flexible about token sources and validation strategies. - -`TokenVerifier` focuses exclusively on token validation without providing OAuth discovery metadata. This makes it ideal for internal systems where clients already know how to obtain tokens, or for microservices that trust tokens from known issuers. - -The class validates token signatures, checks expiration timestamps, and extracts authorization information from token claims. It supports various token formats and validation strategies while maintaining a consistent interface for authorization decisions. - -You can subclass `TokenVerifier` to implement custom validation logic for specialized token formats or validation requirements. The base class handles common patterns while allowing extension for unique use cases. - -## JWT Token Verification - -JSON Web Tokens (JWTs) represent the most common token format for modern applications. FastMCP's `JWTVerifier` validates JWTs using industry-standard cryptographic techniques and claim validation. - -### JWKS Endpoint Integration - -JWKS endpoint integration provides the most flexible approach for production systems. The verifier automatically fetches public keys from a JSON Web Key Set endpoint, enabling automatic key rotation without server configuration changes. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.jwt import JWTVerifier - -# Configure JWT verification against your identity provider -verifier = JWTVerifier( - jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", - issuer="https://auth.yourcompany.com", - audience="mcp-production-api" -) - -mcp = FastMCP(name="Protected API", auth=verifier) -``` - -This configuration creates a server that validates JWTs issued by `auth.yourcompany.com`. The verifier periodically fetches public keys from the JWKS endpoint and validates incoming tokens against those keys. Only tokens with the correct issuer and audience claims will be accepted. - -The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server. - -### Symmetric Key Verification (HMAC) - -Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators. - -This approach is commonly used in microservices architectures where services share a secret key, or when your authentication service and MCP server are both managed by the same organization. The HMAC algorithms (HS256, HS384, HS512) provide strong security when the shared secret is properly managed. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.jwt import JWTVerifier - -# Use a shared secret for symmetric key verification -verifier = JWTVerifier( - public_key="your-shared-secret-key-minimum-32-chars", # Despite the name, this accepts symmetric secrets - issuer="internal-auth-service", - audience="mcp-internal-api", - algorithm="HS256" # or HS384, HS512 for stronger security -) - -mcp = FastMCP(name="Internal API", auth=verifier) -``` - -The verifier will validate tokens signed with the same secret using the specified HMAC algorithm. This approach offers several advantages for internal systems: - -- **Simplicity**: No key pair management or certificate distribution -- **Performance**: HMAC operations are typically faster than RSA -- **Compatibility**: Works well with existing microservice authentication patterns - -<Note> -The parameter is named `public_key` for backwards compatibility, but when using HMAC algorithms (HS256/384/512), it accepts the symmetric secret string. -</Note> - -<Warning> -**Security Considerations for Symmetric Keys:** -- Use a strong, randomly generated secret (minimum 32 characters recommended) -- Never expose the secret in logs, error messages, or version control -- Implement secure key distribution and rotation mechanisms -- Consider using asymmetric keys (RSA/ECDSA) for external-facing APIs -</Warning> - -### Static Public Key Verification - -Static public key verification works when you have a fixed RSA or ECDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.jwt import JWTVerifier - -# Use a static public key for token verification -public_key_pem = """-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... ------END PUBLIC KEY-----""" - -verifier = JWTVerifier( - public_key=public_key_pem, - issuer="https://auth.yourcompany.com", - audience="mcp-production-api" -) - -mcp = FastMCP(name="Protected API", auth=verifier) -``` - -This configuration validates tokens using a specific RSA or ECDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys. -## Opaque Token Verification - -Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662). - -### Understanding Opaque Tokens - -Opaque tokens differ fundamentally from JWTs in their verification model. Where JWTs carry signed claims that can be validated locally, opaque tokens require network calls to the issuing authorization server for validation. The authorization server maintains token state and can revoke tokens immediately, providing stronger security guarantees for sensitive operations. - -This approach trades performance (network latency on each validation) for security and flexibility. Authorization servers can revoke opaque tokens instantly, implement complex authorization logic, and maintain detailed audit logs of token usage. Many enterprise OAuth providers default to opaque tokens for these security advantages. - -### Token Introspection Protocol - -RFC 7662 standardizes how resource servers validate opaque tokens. The protocol defines an introspection endpoint where resource servers authenticate using client credentials and receive token metadata including active status, scopes, expiration, and subject identity. - -FastMCP implements this protocol through the `IntrospectionTokenVerifier` class, handling authentication, request formatting, and response parsing according to the specification. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier - -# Configure introspection with your OAuth provider -verifier = IntrospectionTokenVerifier( - introspection_url="https://auth.yourcompany.com/oauth/introspect", - client_id="mcp-resource-server", - client_secret="your-client-secret", - required_scopes=["api:read", "api:write"] -) - -mcp = FastMCP(name="Protected API", auth=verifier) -``` - -The verifier authenticates to the introspection endpoint using client credentials and queries it whenever a bearer token arrives. FastMCP checks whether the token is active and has sufficient scopes before allowing access. - -Two standard client authentication methods are supported, both defined in RFC 6749: - -- **`client_secret_basic`** (default): Sends credentials via HTTP Basic Auth header -- **`client_secret_post`**: Sends credentials in the POST request body - -Most OAuth providers support both methods, though some may require one specifically. Configure the authentication method with the `client_auth_method` parameter: - -```python -# Use POST body authentication instead of Basic Auth -verifier = IntrospectionTokenVerifier( - introspection_url="https://auth.yourcompany.com/oauth/introspect", - client_id="mcp-resource-server", - client_secret="your-client-secret", - client_auth_method="client_secret_post", - required_scopes=["api:read", "api:write"] -) -``` - -## Development and Testing - -Development environments often need simpler token management without the complexity of full JWT infrastructure. FastMCP provides tools specifically designed for these scenarios. - -### Static Token Verification - -Static token verification enables rapid development by accepting predefined tokens with associated claims. This approach eliminates the need for token generation infrastructure during development and testing. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.jwt import StaticTokenVerifier - -# Define development tokens and their associated claims -verifier = StaticTokenVerifier( - tokens={ - "dev-alice-token": { - "client_id": "alice@company.com", - "scopes": ["read:data", "write:data", "admin:users"] - }, - "dev-guest-token": { - "client_id": "guest-user", - "scopes": ["read:data"] - } - }, - required_scopes=["read:data"] -) - -mcp = FastMCP(name="Development Server", auth=verifier) -``` - -Clients can now authenticate using `Authorization: Bearer dev-alice-token` headers. The server will recognize the token and load the associated claims for authorization decisions. This approach enables immediate development without external dependencies. - -<Warning> -Static token verification stores tokens as plain text and should never be used in production environments. It's designed exclusively for development and testing scenarios. -</Warning> - - -### Debug/Custom Token Verification - -<VersionBadge version="2.13.1" /> - -The `DebugTokenVerifier` provides maximum flexibility for testing and special cases where standard token verification isn't applicable. It delegates validation to a user-provided callable, making it useful for prototyping, testing scenarios, or handling opaque tokens without introspection endpoints. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.debug import DebugTokenVerifier - -# Accept all tokens (useful for rapid development) -verifier = DebugTokenVerifier() - -mcp = FastMCP(name="Development Server", auth=verifier) -``` - -By default, `DebugTokenVerifier` accepts any non-empty token as valid. This eliminates authentication barriers during early development, allowing you to focus on core functionality before adding security. - -For more controlled testing, provide custom validation logic: - -```python -from fastmcp.server.auth.providers.debug import DebugTokenVerifier - -# Synchronous validation - check token prefix -verifier = DebugTokenVerifier( - validate=lambda token: token.startswith("dev-"), - client_id="development-client", - scopes=["read", "write"] -) - -mcp = FastMCP(name="Development Server", auth=verifier) -``` - -The validation callable can also be async, enabling database lookups or external service calls: - -```python -from fastmcp.server.auth.providers.debug import DebugTokenVerifier - -# Asynchronous validation - check against cache -async def validate_token(token: str) -> bool: - # Check if token exists in Redis, database, etc. - return await redis.exists(f"valid_tokens:{token}") - -verifier = DebugTokenVerifier( - validate=validate_token, - client_id="api-client", - scopes=["api:access"] -) - -mcp = FastMCP(name="Custom API", auth=verifier) -``` - -**Use Cases:** - -- **Testing**: Accept any token during integration tests without setting up token infrastructure -- **Prototyping**: Quickly validate concepts without authentication complexity -- **Opaque tokens without introspection**: When you have tokens from an IDP that provides no introspection endpoint, and you're willing to accept tokens without validation (validation happens later at the upstream service) -- **Custom token formats**: Implement validation for non-standard token formats or legacy systems - -<Warning> -`DebugTokenVerifier` bypasses standard security checks. Only use in controlled environments (development, testing) or when you fully understand the security implications. For production, use proper JWT or introspection-based verification. -</Warning> - -### Test Token Generation - -Test token generation helps when you need to test JWT verification without setting up complete identity infrastructure. FastMCP includes utilities for generating test key pairs and signed tokens. - -```python -from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair - -# Generate a key pair for testing -key_pair = RSAKeyPair.generate() - -# Configure your server with the public key -verifier = JWTVerifier( - public_key=key_pair.public_key, - issuer="https://test.yourcompany.com", - audience="test-mcp-server" -) - -# Generate a test token using the private key -test_token = key_pair.create_token( - subject="test-user-123", - issuer="https://test.yourcompany.com", - audience="test-mcp-server", - scopes=["read", "write", "admin"] -) - -print(f"Test token: {test_token}") -``` - -This pattern enables comprehensive testing of JWT validation logic without depending on external token issuers. The generated tokens are cryptographically valid and will pass all standard JWT validation checks. - -## HTTP Client Customization - -<VersionBadge version="2.18.0" /> - -All token verifiers that make HTTP calls accept an optional `http_client` parameter. This lets you provide your own `httpx.AsyncClient` for connection pooling, custom TLS configuration, or proxy settings. - -### Connection Pooling - -By default, each token verification call creates a fresh HTTP client. Under high load, this means repeated TCP connections and TLS handshakes. Providing a shared client enables connection pooling across calls: - -```python -import httpx -from fastmcp import FastMCP -from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier - -# Create a shared client with connection pooling -http_client = httpx.AsyncClient( - timeout=10, - limits=httpx.Limits(max_connections=20, max_keepalive_connections=10), -) - -verifier = IntrospectionTokenVerifier( - introspection_url="https://auth.yourcompany.com/oauth/introspect", - client_id="mcp-resource-server", - client_secret="your-client-secret", - http_client=http_client, -) - -mcp = FastMCP(name="Protected API", auth=verifier) -``` - -The same pattern works for `JWTVerifier` when using JWKS endpoints: - -```python -from fastmcp.server.auth.providers.jwt import JWTVerifier - -verifier = JWTVerifier( - jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", - issuer="https://auth.yourcompany.com", - http_client=http_client, -) -``` - -<Warning> -`JWTVerifier` does not support `http_client` when `ssrf_safe=True`. SSRF-safe mode requires a hardened transport that validates DNS resolution and connection targets, which cannot be guaranteed with a user-provided client. Attempting to use both will raise a `ValueError`. -</Warning> - -<Note> -When you provide an `http_client`, you are responsible for its lifecycle. The verifier will not close it. Use the server's `lifespan` to manage client cleanup: - -```python -from contextlib import asynccontextmanager -from fastmcp import FastMCP -from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier - -http_client = httpx.AsyncClient(timeout=10) - -verifier = IntrospectionTokenVerifier( - introspection_url="https://auth.example.com/introspect", - client_id="my-service", - client_secret="secret", - http_client=http_client, -) - -@asynccontextmanager -async def lifespan(app): - yield - await http_client.aclose() - -mcp = FastMCP(name="My API", auth=verifier, lifespan=lifespan) -``` -</Note> - -The convenience providers (`GitHubProvider`, `GoogleProvider`, `DiscordProvider`, `WorkOSProvider`, `AzureProvider`) also accept `http_client` and pass it through to their internal token verifier. - -## Production Configuration - -For production deployments, load sensitive configuration from environment variables: - -```python -import os -from fastmcp import FastMCP -from fastmcp.server.auth.providers.jwt import JWTVerifier - -# Load configuration from environment variables -# Parse comma-separated scopes if provided -scopes_env = os.environ.get("JWT_REQUIRED_SCOPES") -required_scopes = scopes_env.split(",") if scopes_env else None - -verifier = JWTVerifier( - jwks_uri=os.environ.get("JWT_JWKS_URI"), - issuer=os.environ.get("JWT_ISSUER"), - audience=os.environ.get("JWT_AUDIENCE"), - required_scopes=required_scopes, -) - -mcp = FastMCP(name="Production API", auth=verifier) -``` - -This keeps configuration out of your codebase while maintaining explicit setup. - -This approach enables the same codebase to run across development, staging, and production environments with different authentication requirements. Development might use static tokens while production uses JWT verification, all controlled through environment configuration. - diff --git a/docs/v3/servers/authorization.mdx b/docs/v3/servers/authorization.mdx deleted file mode 100644 index a48d2a9e8..000000000 --- a/docs/v3/servers/authorization.mdx +++ /dev/null @@ -1,384 +0,0 @@ ---- -title: Authorization -sidebarTitle: Authorization -description: Control access to components using callable-based authorization checks that filter visibility and enforce permissions. -icon: shield-halved -tag: NEW ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="3.0.0" /> - -Authorization controls what authenticated users can do with your FastMCP server. While [authentication](/servers/auth/authentication) verifies identity (who you are), authorization determines access (what you can do). FastMCP provides a callable-based authorization system that works at both the component level and globally via middleware. - -The authorization model centers on a simple concept: callable functions that receive context about the current request and return `True` to allow access or `False` to deny it. Multiple checks combine with AND logic, meaning all checks must pass for access to be granted. - -<Note> -Authorization relies on OAuth tokens which are only available with HTTP transports (SSE, Streamable HTTP). In STDIO mode, there's no OAuth mechanism, so `get_access_token()` returns `None` and all auth checks are skipped. -</Note> - -<Note> -When an `AuthProvider` is configured, all requests to the MCP endpoint must carry a valid token—unauthenticated requests are rejected at the transport level before any auth checks run. Authorization checks therefore differentiate between authenticated users based on their scopes and claims, not between authenticated and unauthenticated users. -</Note> - -## Auth Checks - -An auth check is any callable that accepts an `AuthContext` and returns a boolean. Auth checks can be synchronous or asynchronous, so checks that need to perform async operations (like reading server state or calling external services) work naturally. - -```python -from fastmcp.server.auth import AuthContext - -def my_custom_check(ctx: AuthContext) -> bool: - # ctx.token is AccessToken | None - # ctx.component is the Tool, Resource, or Prompt being accessed - return ctx.token is not None and "special" in ctx.token.scopes -``` - -FastMCP provides two built-in auth checks that cover common authorization patterns. - -### require_scopes - -Scope-based authorization checks that the token contains all specified OAuth scopes. When multiple scopes are provided, all must be present (AND logic). - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_scopes - -mcp = FastMCP("Scoped Server") - -@mcp.tool(auth=require_scopes("admin")) -def admin_operation() -> str: - """Requires the 'admin' scope.""" - return "Admin action completed" - -@mcp.tool(auth=require_scopes("read", "write")) -def read_write_operation() -> str: - """Requires both 'read' AND 'write' scopes.""" - return "Read/write action completed" -``` - -### restrict_tag - -Tag-based restrictions apply scope requirements conditionally. If a component has the specified tag, the token must have the required scopes. Components without the tag are unaffected. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import restrict_tag -from fastmcp.server.middleware import AuthMiddleware - -mcp = FastMCP( - "Tagged Server", - middleware=[ - AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) - ] -) - -@mcp.tool(tags={"admin"}) -def admin_tool() -> str: - """Tagged 'admin', so requires 'admin' scope.""" - return "Admin only" - -@mcp.tool(tags={"public"}) -def public_tool() -> str: - """Not tagged 'admin', so no scope required by the restriction.""" - return "Anyone can access" -``` - -### Combining Checks - -Multiple auth checks can be combined by passing a list. All checks must pass for authorization to succeed (AND logic). - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_scopes - -mcp = FastMCP("Combined Auth Server") - -@mcp.tool(auth=[require_scopes("admin"), require_scopes("write")]) -def secure_admin_action() -> str: - """Requires both 'admin' AND 'write' scopes.""" - return "Secure admin action" -``` - -### Custom Auth Checks - -Any callable that accepts `AuthContext` and returns `bool` can serve as an auth check. This enables authorization logic based on token claims, component metadata, or external systems. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import AuthContext - -mcp = FastMCP("Custom Auth Server") - -def require_premium_user(ctx: AuthContext) -> bool: - """Check for premium user status in token claims.""" - if ctx.token is None: - return False - return ctx.token.claims.get("premium", False) is True - -def require_access_level(minimum_level: int): - """Factory function for level-based authorization.""" - def check(ctx: AuthContext) -> bool: - if ctx.token is None: - return False - user_level = ctx.token.claims.get("level", 0) - return user_level >= minimum_level - return check - -@mcp.tool(auth=require_premium_user) -def premium_feature() -> str: - """Only for premium users.""" - return "Premium content" - -@mcp.tool(auth=require_access_level(5)) -def advanced_feature() -> str: - """Requires access level 5 or higher.""" - return "Advanced feature" -``` - -### Async Auth Checks - -Auth checks can be `async` functions, which is useful when the authorization decision depends on asynchronous operations like reading server state or querying external services. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import AuthContext - -mcp = FastMCP("Async Auth Server") - -async def check_user_permissions(ctx: AuthContext) -> bool: - """Async auth check that reads server state.""" - if ctx.token is None: - return False - user_id = ctx.token.claims.get("sub") - # Async operations work naturally in auth checks - permissions = await fetch_user_permissions(user_id) - return "admin" in permissions - -@mcp.tool(auth=check_user_permissions) -def admin_tool() -> str: - return "Admin action completed" -``` - -Sync and async checks can be freely combined in a list — each check is handled according to its type. - -### Error Handling - -Auth checks can raise exceptions for explicit denial with custom messages: - -- **`AuthorizationError`**: Propagates with its custom message, useful for explaining why access was denied -- **Other exceptions**: Masked for security (logged internally, treated as denial) - -```python -from fastmcp.server.auth import AuthContext -from fastmcp.exceptions import AuthorizationError - -def require_verified_email(ctx: AuthContext) -> bool: - """Require verified email with explicit denial message.""" - if ctx.token is None: - raise AuthorizationError("Authentication required") - if not ctx.token.claims.get("email_verified"): - raise AuthorizationError("Email verification required") - return True -``` - -## Component-Level Authorization - -The `auth` parameter on decorators controls visibility and access for individual components. When auth checks fail for the current request, the component is hidden from list responses and direct access returns not-found. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_scopes - -mcp = FastMCP("Component Auth Server") - -@mcp.tool(auth=require_scopes("write")) -def write_tool() -> str: - """Only visible to users with 'write' scope.""" - return "Written" - -@mcp.resource("secret://data", auth=require_scopes("read")) -def secret_resource() -> str: - """Only visible to users with 'read' scope.""" - return "Secret data" - -@mcp.prompt(auth=require_scopes("admin")) -def admin_prompt() -> str: - """Only visible to users with 'admin' scope.""" - return "Admin prompt content" -``` - -<Note> -Component-level `auth` controls both visibility (list filtering) and access (direct lookups return not-found for unauthorized requests). Additionally use `AuthMiddleware` to apply server-wide authorization rules and get explicit `AuthorizationError` responses on unauthorized execution attempts. -</Note> - -## Server-Level Authorization - -For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_scopes -from fastmcp.server.middleware import AuthMiddleware - -mcp = FastMCP( - "Enforced Auth Server", - middleware=[AuthMiddleware(auth=require_scopes("api"))] -) - -@mcp.tool -def any_tool() -> str: - """Requires 'api' scope to see AND call.""" - return "Protected" -``` - -### Component Auth + Middleware - -Component-level `auth` and `AuthMiddleware` work together as complementary layers. The middleware applies server-wide rules to all components, while component-level auth adds per-component requirements. Both layers are checked—all checks must pass. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import require_scopes, restrict_tag -from fastmcp.server.middleware import AuthMiddleware - -mcp = FastMCP( - "Layered Auth Server", - middleware=[ - AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) - ] -) - -# Requires "write" scope (component-level) -# Also requires "admin" scope if tagged "admin" (middleware-level) -@mcp.tool(auth=require_scopes("write"), tags={"admin"}) -def admin_write() -> str: - """Requires both 'write' AND 'admin' scopes.""" - return "Admin write" - -# Requires "write" scope (component-level only) -@mcp.tool(auth=require_scopes("write")) -def user_write() -> str: - """Requires 'write' scope.""" - return "User write" -``` - -### Tag-Based Global Authorization - -A common pattern uses `restrict_tag` with `AuthMiddleware` to apply scope requirements based on component tags. - -```python -from fastmcp import FastMCP -from fastmcp.server.auth import restrict_tag -from fastmcp.server.middleware import AuthMiddleware - -mcp = FastMCP( - "Tag-Based Auth Server", - middleware=[ - AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])), - AuthMiddleware(auth=restrict_tag("write", scopes=["write"])), - ] -) - -@mcp.tool(tags={"admin"}) -def delete_all_data() -> str: - """Requires 'admin' scope.""" - return "Deleted" - -@mcp.tool(tags={"write"}) -def update_record(id: str, data: str) -> str: - """Requires 'write' scope.""" - return f"Updated {id}" - -@mcp.tool -def read_record(id: str) -> str: - """No tag restrictions, accessible to all.""" - return f"Record {id}" -``` - -## Accessing Tokens in Tools - -Tools can access the current authentication token using `get_access_token()` from `fastmcp.server.dependencies`. This enables tools to make decisions based on user identity or permissions beyond simple authorization checks. - -```python -from fastmcp import FastMCP -from fastmcp.server.dependencies import get_access_token - -mcp = FastMCP("Token Access Server") - -@mcp.tool -def personalized_greeting() -> str: - """Greet the user based on their token claims.""" - token = get_access_token() - - if token is None: - return "Hello, guest!" - - name = token.claims.get("name", "user") - return f"Hello, {name}!" - -@mcp.tool -def user_dashboard() -> dict: - """Return user-specific data based on token.""" - token = get_access_token() - - if token is None: - return {"error": "Not authenticated"} - - return { - "client_id": token.client_id, - "scopes": token.scopes, - "claims": token.claims, - } -``` - -## Reference - -### AccessToken - -The `AccessToken` object contains information extracted from the OAuth token. - -| Property | Type | Description | -|----------|------|-------------| -| `token` | `str` | The raw token string | -| `client_id` | `str \| None` | OAuth client identifier | -| `scopes` | `list[str]` | Granted OAuth scopes | -| `expires_at` | `datetime \| None` | Token expiration time | -| `claims` | `dict[str, Any]` | All JWT claims or custom token data | - -### AuthContext - -The `AuthContext` dataclass is passed to all auth check functions. - -| Property | Type | Description | -|----------|------|-------------| -| `token` | `AccessToken \| None` | Current access token, or `None` if unauthenticated | -| `component` | `Tool \| Resource \| Prompt` | The component being accessed | - -Access to the component object enables authorization decisions based on metadata like tags, name, or custom properties. - -```python -from fastmcp.server.auth import AuthContext - -def require_matching_tag(ctx: AuthContext) -> bool: - """Require a scope matching each of the component's tags.""" - if ctx.token is None: - return False - user_scopes = set(ctx.token.scopes) - return ctx.component.tags.issubset(user_scopes) -``` - -### Imports - -```python -from fastmcp.server.auth import ( - AccessToken, # Token with .token, .client_id, .scopes, .expires_at, .claims - AuthContext, # Context with .token, .component - AuthCheck, # Type alias: sync or async Callable[[AuthContext], bool] - require_scopes, # Built-in: requires specific scopes - restrict_tag, # Built-in: tag-based scope requirements - run_auth_checks, # Utility: run checks with AND logic -) - -from fastmcp.server.middleware import AuthMiddleware -``` diff --git a/docs/v3/servers/composition.mdx b/docs/v3/servers/composition.mdx deleted file mode 100644 index 42523a5cd..000000000 --- a/docs/v3/servers/composition.mdx +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: Composing Servers -sidebarTitle: Composition -description: Combine multiple servers into one -icon: puzzle-piece ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.2.0" /> - -As your application grows, you'll want to split it into focused servers — one for weather, one for calendar, one for admin — and combine them into a single server that clients connect to. That's what `mount()` does. - -When you mount a server, all its tools, resources, and prompts become available through the parent. The connection is live: add a tool to the child after mounting, and it's immediately visible through the parent. - -```python -from fastmcp import FastMCP - -weather = FastMCP("Weather") - -@weather.tool -def get_forecast(city: str) -> str: - """Get weather forecast for a city.""" - return f"Sunny in {city}" - -@weather.resource("data://cities") -def list_cities() -> list[str]: - """List supported cities.""" - return ["London", "Paris", "Tokyo"] - -main = FastMCP("MainApp") -main.mount(weather) - -# main now serves get_forecast and data://cities -``` - -## Mounting External Servers - -Mount remote HTTP servers or subprocess-based MCP servers using `create_proxy()`: - -```python -from fastmcp import FastMCP -from fastmcp.server import create_proxy - -mcp = FastMCP("Orchestrator") - -# Mount a remote HTTP server (URLs work directly) -mcp.mount(create_proxy("http://api.example.com/mcp"), namespace="api") - -# Mount local Python scripts (file paths work directly) -mcp.mount(create_proxy("./my_server.py"), namespace="local") -``` - -### Mounting npm/uvx Packages - -For npm packages or Python tools, use the config dict format: - -```python -from fastmcp import FastMCP -from fastmcp.server import create_proxy - -mcp = FastMCP("Orchestrator") - -# Mount npm package via config -github_config = { - "mcpServers": { - "default": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"] - } - } -} -mcp.mount(create_proxy(github_config), namespace="github") - -# Mount Python tool via config -sqlite_config = { - "mcpServers": { - "default": { - "command": "uvx", - "args": ["mcp-server-sqlite", "--db", "data.db"] - } - } -} -mcp.mount(create_proxy(sqlite_config), namespace="db") -``` - -Or use explicit transport classes: - -```python -from fastmcp import FastMCP -from fastmcp.server import create_proxy -from fastmcp.client.transports import NpxStdioTransport, UvxStdioTransport - -mcp = FastMCP("Orchestrator") - -mcp.mount( - create_proxy(NpxStdioTransport(package="@modelcontextprotocol/server-github")), - namespace="github" -) -mcp.mount( - create_proxy(UvxStdioTransport(tool_name="mcp-server-sqlite", tool_args=["--db", "data.db"])), - namespace="db" -) -``` - -For advanced configuration, see [Proxying](/servers/providers/proxy). - -## Namespacing - -<VersionBadge version="3.0.0" /> - -When mounting multiple servers, use namespaces to avoid naming conflicts: - -```python -weather = FastMCP("Weather") -calendar = FastMCP("Calendar") - -@weather.tool -def get_data() -> str: - return "Weather data" - -@calendar.tool -def get_data() -> str: - return "Calendar data" - -main = FastMCP("Main") -main.mount(weather, namespace="weather") -main.mount(calendar, namespace="calendar") - -# Tools are now: -# - weather_get_data -# - calendar_get_data -``` - -### How Namespacing Works - -| Component Type | Without Namespace | With `namespace="api"` | -|----------------|-------------------|------------------------| -| Tool | `my_tool` | `api_my_tool` | -| Prompt | `my_prompt` | `api_my_prompt` | -| Resource | `data://info` | `data://api/info` | -| Template | `data://{id}` | `data://api/{id}` | - -Namespacing uses [transforms](/servers/transforms/transforms) under the hood. - -## Dynamic Composition - -Because `mount()` creates a live link, you can add components to a child server after mounting and they'll be immediately available through the parent: - -```python -main = FastMCP("Main") -main.mount(dynamic_server, namespace="dynamic") - -# Add a tool AFTER mounting - it's accessible through main -@dynamic_server.tool -def added_later() -> str: - return "Added after mounting!" -``` - -## Tag Filtering - -<VersionBadge version="3.0.0" /> - -Parent server tag filters apply recursively to mounted servers: - -```python -api_server = FastMCP("API") - -@api_server.tool(tags={"production"}) -def prod_endpoint() -> str: - return "Production data" - -@api_server.tool(tags={"development"}) -def dev_endpoint() -> str: - return "Debug data" - -# Mount with production filter -prod_app = FastMCP("Production") -prod_app.mount(api_server, namespace="api") -prod_app.enable(tags={"production"}, only=True) - -# Only prod_endpoint (namespaced as api_prod_endpoint) is visible -``` - -## Performance Considerations - -Operations like `list_tools()` on the parent are affected by the performance of all mounted servers. This is particularly noticeable with: - -- HTTP-based mounted servers (300-400ms vs 1-2ms for local tools) -- Mounted servers with slow initialization -- Deep mounting hierarchies - -If low latency is critical, consider implementing caching strategies or limiting mounting depth. - -## Custom Routes - -<VersionBadge version="2.4.0" /> - -Custom HTTP routes defined with `@server.custom_route()` are also forwarded when mounting: - -```python -subserver = FastMCP("Sub") - -@subserver.custom_route("/health", methods=["GET"]) -async def health_check(): - return {"status": "ok"} - -main = FastMCP("Main") -main.mount(subserver, namespace="sub") - -# /health is now accessible through main's HTTP app -``` - -## Conflict Resolution - -<VersionBadge version="3.0.0" /> - -When mounting multiple servers with the same namespace (or no namespace), the **most recently mounted** server takes precedence for conflicting component names: - -```python -server_a = FastMCP("A") -server_b = FastMCP("B") - -@server_a.tool -def shared_tool() -> str: - return "From A" - -@server_b.tool -def shared_tool() -> str: - return "From B" - -main = FastMCP("Main") -main.mount(server_a) -main.mount(server_b) - -# shared_tool returns "From B" (most recently mounted) -``` diff --git a/docs/v3/servers/context.mdx b/docs/v3/servers/context.mdx deleted file mode 100644 index d442743ab..000000000 --- a/docs/v3/servers/context.mdx +++ /dev/null @@ -1,480 +0,0 @@ ---- -title: MCP Context -sidebarTitle: Context -description: Access MCP capabilities like logging, progress, and resources within your MCP objects. -icon: rectangle-code -tag: NEW ---- -import { VersionBadge } from '/snippets/version-badge.mdx' - -When defining FastMCP [tools](/servers/tools), [resources](/servers/resources), resource templates, or [prompts](/servers/prompts), your functions might need to interact with the underlying MCP session or access advanced server capabilities. FastMCP provides the `Context` object for this purpose. - -<Note> -You access Context through FastMCP's dependency injection system. For other injectable values like HTTP requests, access tokens, and custom dependencies, see [Dependency Injection](/servers/dependency-injection). -</Note> - -## What Is Context? - -The `Context` object provides a clean interface to access MCP features within your functions, including: - -- **Logging**: Send debug, info, warning, and error messages back to the client -- **Progress Reporting**: Update the client on the progress of long-running operations -- **Resource Access**: List and read data from resources registered with the server -- **Prompt Access**: List and retrieve prompts registered with the server -- **LLM Sampling**: Request the client's LLM to generate text based on provided messages -- **User Elicitation**: Request structured input from users during tool execution -- **Session State**: Store data that persists across requests within an MCP session -- **Session Visibility**: [Control which components are visible](/servers/visibility#per-session-visibility) to the current session -- **Request Information**: Access metadata about the current request -- **Server Access**: When needed, access the underlying FastMCP server instance - -## Accessing the Context - -<VersionBadge version="2.14" /> - -The preferred way to access context is using the `CurrentContext()` dependency: - -```python {1, 6} -from fastmcp import FastMCP -from fastmcp.dependencies import CurrentContext -from fastmcp.server.context import Context - -mcp = FastMCP(name="Context Demo") - -@mcp.tool -async def process_file(file_uri: str, ctx: Context = CurrentContext()) -> str: - """Processes a file, using context for logging and resource access.""" - await ctx.info(f"Processing {file_uri}") - return "Processed file" -``` - -This works with tools, resources, and prompts: - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import CurrentContext -from fastmcp.server.context import Context - -mcp = FastMCP(name="Context Demo") - -@mcp.resource("resource://user-data") -async def get_user_data(ctx: Context = CurrentContext()) -> dict: - await ctx.debug("Fetching user data") - return {"user_id": "example"} - -@mcp.prompt -async def data_analysis_request(dataset: str, ctx: Context = CurrentContext()) -> str: - return f"Please analyze the following dataset: {dataset}" -``` - -**Key Points:** - -- Dependency parameters are automatically excluded from the MCP schema—clients never see them. -- Context methods are async, so your function usually needs to be async as well. -- **Each MCP request receives a new context object.** Context is scoped to a single request; state or data set in one request will not be available in subsequent requests. -- Context is only available during a request; attempting to use context methods outside a request will raise errors. - -### Legacy Type-Hint Injection - -For backwards compatibility, you can still access context by simply adding a parameter with the `Context` type hint. FastMCP will automatically inject the context instance: - -```python {1, 6} -from fastmcp import FastMCP, Context - -mcp = FastMCP(name="Context Demo") - -@mcp.tool -async def process_file(file_uri: str, ctx: Context) -> str: - """Processes a file, using context for logging and resource access.""" - # Context is injected automatically based on the type hint - return "Processed file" -``` - -This approach still works for tools, resources, and prompts. The parameter name doesn't matter—only the `Context` type hint is important. The type hint can also be a union (`Context | None`) or use `Annotated[]`. - -### Via `get_context()` Function - -<VersionBadge version="2.2.11" /> - -For code nested deeper within your function calls where passing context through parameters is inconvenient, use `get_context()` to retrieve the active context from anywhere within a request's execution flow: - -```python {2,9} -from fastmcp import FastMCP -from fastmcp.server.dependencies import get_context - -mcp = FastMCP(name="Dependency Demo") - -# Utility function that needs context but doesn't receive it as a parameter -async def process_data(data: list[float]) -> dict: - # Get the active context - only works when called within a request - ctx = get_context() - await ctx.info(f"Processing {len(data)} data points") - -@mcp.tool -async def analyze_dataset(dataset_name: str) -> dict: - # Call utility function that uses context internally - data = load_data(dataset_name) - await process_data(data) -``` - -**Important Notes:** - -- The `get_context()` function should only be used within the context of a server request. Calling it outside of a request will raise a `RuntimeError`. -- The `get_context()` function is server-only and should not be used in client code. - -## Context Capabilities - -FastMCP provides several advanced capabilities through the context object. Each capability has dedicated documentation with comprehensive examples and best practices: - -### Logging - -Send debug, info, warning, and error messages back to the MCP client for visibility into function execution. - -```python -await ctx.debug("Starting analysis") -await ctx.info(f"Processing {len(data)} items") -await ctx.warning("Deprecated parameter used") -await ctx.error("Processing failed") -``` - -See [Server Logging](/servers/logging) for complete documentation and examples. -### Client Elicitation - -<VersionBadge version="2.10.0" /> - -Request structured input from clients during tool execution, enabling interactive workflows and progressive disclosure. This is a new feature in the 6/18/2025 MCP spec. - -```python -result = await ctx.elicit("Enter your name:", response_type=str) -if result.action == "accept": - name = result.data -``` - -See [User Elicitation](/servers/elicitation) for detailed examples and supported response types. - -### LLM Sampling - -<VersionBadge version="2.0.0" /> - -Request the client's LLM to generate text based on provided messages, useful for leveraging AI capabilities within your tools. - -```python -response = await ctx.sample("Analyze this data", temperature=0.7) -``` - -See [LLM Sampling](/servers/sampling) for comprehensive usage and advanced techniques. - - -### Progress Reporting - -Update clients on the progress of long-running operations, enabling progress indicators and better user experience. - -```python -await ctx.report_progress(progress=50, total=100) # 50% complete -``` - -See [Progress Reporting](/servers/progress) for detailed patterns and examples. - -### Resource Access - -List and read data from resources registered with your FastMCP server, allowing access to files, configuration, or dynamic content. - -```python -# List available resources -resources = await ctx.list_resources() - -# Read a specific resource -content_list = await ctx.read_resource("resource://config") -content = content_list[0].content -``` - -**Method signatures:** -- **`ctx.list_resources() -> list[MCPResource]`**: <VersionBadge version="2.13.0" /> Returns list of all available resources -- **`ctx.read_resource(uri: str | AnyUrl) -> list[ReadResourceContents]`**: Returns a list of resource content parts - -### Prompt Access - -<VersionBadge version="2.13.0" /> - -List and retrieve prompts registered with your FastMCP server, allowing tools and middleware to discover and use available prompts programmatically. - -```python -# List available prompts -prompts = await ctx.list_prompts() - -# Get a specific prompt with arguments -result = await ctx.get_prompt("analyze_data", {"dataset": "users"}) -messages = result.messages -``` - -**Method signatures:** -- **`ctx.list_prompts() -> list[MCPPrompt]`**: Returns list of all available prompts -- **`ctx.get_prompt(name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult`**: Get a specific prompt with optional arguments - -### Session State - -<VersionBadge version="3.0.0" /> - -Store data that persists across multiple requests within the same MCP session. Session state is automatically keyed by the client's session, ensuring isolation between different clients. - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP("stateful-app") - -@mcp.tool -async def increment_counter(ctx: Context) -> int: - """Increment a counter that persists across tool calls.""" - count = await ctx.get_state("counter") or 0 - await ctx.set_state("counter", count + 1) - return count + 1 - -@mcp.tool -async def get_counter(ctx: Context) -> int: - """Get the current counter value.""" - return await ctx.get_state("counter") or 0 -``` - -Each client session has its own isolated state—two different clients calling `increment_counter` will each have their own counter. - -**Method signatures:** -- **`await ctx.set_state(key, value, *, serializable=True)`**: Store a value in session state -- **`await ctx.get_state(key)`**: Retrieve a value (returns None if not found) -- **`await ctx.delete_state(key)`**: Remove a value from session state - -<Note> -State methods are async and require `await`. State expires after 1 day to prevent unbounded memory growth. -</Note> - -#### Non-Serializable Values - -By default, state values must be JSON-serializable (dicts, lists, strings, numbers, etc.) so they can be persisted across requests. For non-serializable values like HTTP clients or database connections, pass `serializable=False`: - -```python -@mcp.tool -async def my_tool(ctx: Context) -> str: - # This object can't be JSON-serialized - client = SomeHTTPClient(base_url="https://api.example.com") - await ctx.set_state("client", client, serializable=False) - - # Retrieve it later in the same request - client = await ctx.get_state("client") - return await client.fetch("/data") -``` - -Values stored with `serializable=False` only live for the current MCP request (a single tool call, resource read, or prompt render). They will not be available in subsequent requests within the session. - -#### Custom Storage Backends - -By default, session state uses an in-memory store suitable for single-server deployments. For distributed or serverless deployments, provide a custom storage backend: - -```python -from key_value.aio.stores.redis import RedisStore - -# Use Redis for distributed state -mcp = FastMCP("distributed-app", session_state_store=RedisStore(...)) -``` - -Any backend compatible with the [py-key-value-aio](https://github.com/strawgate/py-key-value) `AsyncKeyValue` protocol works. See [Storage Backends](/servers/storage-backends) for more options including Redis, DynamoDB, and MongoDB. - -#### State and Mounted Servers - -Each `FastMCP` instance has its own session state store. When you `mount()` a child server, state set on the parent is not visible to tools on the child, and vice versa: - -```python -from fastmcp import FastMCP, Context -from fastmcp.server.middleware import Middleware, MiddlewareContext - -parent = FastMCP("Parent") -child = FastMCP("Child") -parent.mount(child, namespace="child") - -class Stasher(Middleware): - async def on_call_tool(self, context: MiddlewareContext, call_next): - await context.fastmcp_context.set_state("user", "alice") - return await call_next(context) - -parent.add_middleware(Stasher()) - -@child.tool -async def whoami(ctx: Context) -> str: - return await ctx.get_state("user") or "unknown" # returns "unknown" -``` - -To share state across the mount boundary, pass the same store to both servers: - -```python -from key_value.aio.stores.memory import MemoryStore - -store = MemoryStore() -parent = FastMCP("Parent", session_state_store=store) -child = FastMCP("Child", session_state_store=store) -parent.mount(child, namespace="child") -``` - -Alternatively, state set with `serializable=False` lives on the request context and is inherited by mounted children automatically — use it when the value is request-scoped and does not need to persist across tool calls. - -#### State During Initialization - -State set during `on_initialize` middleware persists to subsequent tool calls when using the same session object (STDIO, SSE, single-server HTTP). For distributed/serverless HTTP deployments where different machines handle init and tool calls, state is isolated by the `mcp-session-id` header. - -### Session Visibility - -<VersionBadge version="3.0.0" /> - -Tools can customize which components are visible to their current session using `ctx.enable_components()`, `ctx.disable_components()`, and `ctx.reset_visibility()`. These methods apply visibility rules that affect only the calling session, leaving other sessions unchanged. See [Per-Session Visibility](/servers/visibility#per-session-visibility) for complete documentation, filter criteria, and patterns like namespace activation. - -### Change Notifications - -<VersionBadge version="3.0.0" /> - -FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context's notification methods: - -```python -import mcp.types - -@mcp.tool -async def custom_tool_management(ctx: Context) -> str: - """Example of manual notification after custom tool changes.""" - await ctx.send_notification(mcp.types.ToolListChangedNotification()) - await ctx.send_notification(mcp.types.ResourceListChangedNotification()) - await ctx.send_notification(mcp.types.PromptListChangedNotification()) - return "Notifications sent" -``` - -These methods are primarily used internally by FastMCP's automatic notification system and most users will not need to invoke them directly. - -### FastMCP Server - -To access the underlying FastMCP server instance, you can use the `ctx.fastmcp` property: - -```python -@mcp.tool -async def my_tool(ctx: Context) -> None: - # Access the FastMCP server instance - server_name = ctx.fastmcp.name - ... -``` - -### Transport - -<VersionBadge version="3.0.0" /> - -The `ctx.transport` property indicates which transport is being used to run the server. This is useful when your tool needs to behave differently depending on whether the server is running over STDIO, SSE, or Streamable HTTP. For example, you might want to return shorter responses over STDIO or adjust timeout behavior based on transport characteristics. - -The transport type is set once when the server starts and remains constant for the server's lifetime. It returns `None` when called outside of a server context (for example, in unit tests or when running code outside of an MCP request). - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP("example") - -@mcp.tool -def connection_info(ctx: Context) -> str: - if ctx.transport == "stdio": - return "Connected via STDIO" - elif ctx.transport == "sse": - return "Connected via SSE" - elif ctx.transport == "streamable-http": - return "Connected via Streamable HTTP" - else: - return "Transport unknown" -``` - -**Property signature:** `ctx.transport -> Literal["stdio", "sse", "streamable-http"] | None` - -### MCP Request - -Access metadata about the current request and client. - -```python -@mcp.tool -async def request_info(ctx: Context) -> dict: - """Return information about the current request.""" - return { - "request_id": ctx.request_id, - "client_id": ctx.client_id or "Unknown client" - } -``` - -**Available Properties:** - -- **`ctx.request_id -> str`**: Get the unique ID for the current MCP request -- **`ctx.client_id -> str | None`**: Get the ID of the client making the request, if provided during initialization -- **`ctx.session_id -> str`**: Get the MCP session ID for session-based data sharing. Raises `RuntimeError` if the MCP session is not yet established. - -#### Request Context Availability - -<VersionBadge version="2.13.1" /> - -The `ctx.request_context` property provides access to the underlying MCP request context, but returns `None` when the MCP session has not been established yet. This typically occurs: - -- During middleware execution in the `on_request` hook before the MCP handshake completes -- During the initialization phase of client connections - -The MCP request context is distinct from the HTTP request. For HTTP transports, HTTP request data may be available even when the MCP session is not yet established. - -To safely access the request context in situations where it may not be available: - -```python -from fastmcp import FastMCP, Context -from fastmcp.server.dependencies import get_http_request - -mcp = FastMCP(name="Session Aware Demo") - -@mcp.tool -async def session_info(ctx: Context) -> dict: - """Return session information when available.""" - - # Check if MCP session is available - if ctx.request_context: - # MCP session available - can access MCP-specific attributes - return { - "session_id": ctx.session_id, - "request_id": ctx.request_id, - "has_meta": ctx.request_context.meta is not None - } - else: - # MCP session not available - use HTTP helpers for request data (if using HTTP transport) - request = get_http_request() - return { - "message": "MCP session not available", - "user_agent": request.headers.get("user-agent", "Unknown") - } -``` - -For HTTP request access that works regardless of MCP session availability (when using HTTP transports), use the [HTTP request helpers](/servers/dependency-injection#http-request) like `get_http_request()` and `get_http_headers()`. - -#### Client Metadata - -<VersionBadge version="2.13.1" /> - -Clients can send contextual information with their requests using the `meta` parameter. This metadata is accessible through `ctx.request_context.meta` and is available for all MCP operations (tools, resources, prompts). - -The `meta` field is `None` when clients don't provide metadata. When provided, metadata is accessible via attribute access (e.g., `meta.user_id`) rather than dictionary access. The structure of metadata is determined by the client making the request. - -```python -@mcp.tool -def send_email(to: str, subject: str, body: str, ctx: Context) -> str: - """Send an email, logging metadata about the request.""" - - # Access client-provided metadata - meta = ctx.request_context.meta - - if meta: - # Meta is accessed as an object with attribute access - user_id = meta.user_id if hasattr(meta, 'user_id') else None - trace_id = meta.trace_id if hasattr(meta, 'trace_id') else None - - # Use metadata for logging, observability, etc. - if trace_id: - log_with_trace(f"Sending email for user {user_id}", trace_id) - - # Send the email... - return f"Email sent to {to}" -``` - -<Warning> -The MCP request is part of the low-level MCP SDK and intended for advanced use cases. Most users will not need to use it directly. -</Warning> - diff --git a/docs/v3/servers/dependency-injection.mdx b/docs/v3/servers/dependency-injection.mdx deleted file mode 100644 index 40fc7b65b..000000000 --- a/docs/v3/servers/dependency-injection.mdx +++ /dev/null @@ -1,433 +0,0 @@ ---- -title: Dependency Injection -sidebarTitle: Dependencies -description: Inject runtime values like HTTP requests, access tokens, and custom dependencies into your MCP components. -icon: syringe -tag: NEW ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -FastMCP uses dependency injection to provide runtime values to your tools, resources, and prompts. Instead of passing context through every layer of your code, you declare what you need as parameter defaults—FastMCP resolves them automatically when your function runs. - -The dependency injection system is powered by [Docket](https://github.com/chrisguidry/docket) and its dependency system [uncalled-for](https://github.com/chrisguidry/uncalled-for). Core DI features like `Depends()` and `CurrentContext()` work without installing Docket. For background tasks and advanced task-related dependencies, install `fastmcp[tasks]`. For comprehensive coverage of dependency patterns, see the [Docket dependency documentation](https://docket.lol/en/latest/dependency-injection/). - -<Note> -Dependency parameters are automatically excluded from the MCP schema—clients never see them as callable parameters. This separation keeps your function signatures clean while giving you access to the runtime context you need. -</Note> - -## How Dependency Injection Works - -Dependency injection in FastMCP follows a simple pattern: declare a parameter with a recognized type annotation or a dependency default value, and FastMCP injects the resolved value at runtime. - -```python -from fastmcp import FastMCP -from fastmcp.server.context import Context - -mcp = FastMCP("Demo") - - -@mcp.tool -async def my_tool(query: str, ctx: Context) -> str: - await ctx.info(f"Processing: {query}") - return f"Results for: {query}" -``` - -When a client calls `my_tool`, they only see `query` as a parameter. The `ctx` parameter is injected automatically because it has a `Context` type annotation—FastMCP recognizes this and provides the active context for the request. - -This works identically for tools, resources, resource templates, and prompts. - -### Explicit Dependencies with CurrentContext - -For more explicit code, you can use `CurrentContext()` as a default value instead of relying on the type annotation: - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import CurrentContext -from fastmcp.server.context import Context - -mcp = FastMCP("Demo") - - -@mcp.tool -async def my_tool(query: str, ctx: Context = CurrentContext()) -> str: - await ctx.info(f"Processing: {query}") - return f"Results for: {query}" -``` - -Both approaches work identically. The type-annotation approach is more concise; the explicit `CurrentContext()` approach makes the dependency injection visible in the signature. - -## Built-in Dependencies - -### MCP Context - -The MCP Context provides logging, progress reporting, resource access, and other request-scoped operations. See [MCP Context](/servers/context) for the full API. - -**Dependency injection:** Use a `Context` type annotation (FastMCP injects automatically) or `CurrentContext()`: - -```python -from fastmcp import FastMCP -from fastmcp.server.context import Context - -mcp = FastMCP("Demo") - - -@mcp.tool -async def process_data(data: str, ctx: Context) -> str: - await ctx.info(f"Processing: {data}") - return "Done" - - -# Or explicitly with CurrentContext() -from fastmcp.dependencies import CurrentContext - -@mcp.tool -async def process_data(data: str, ctx: Context = CurrentContext()) -> str: - ... -``` - -**Function:** Use `get_context()` in helper functions or middleware: - -```python -from fastmcp.server.dependencies import get_context - -async def log_something(message: str): - ctx = get_context() - await ctx.info(message) -``` - -### Server Instance - -<VersionBadge version="2.14" /> - -Access the FastMCP server instance for introspection or server-level configuration. - -**Dependency injection:** Use `CurrentFastMCP()`: - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import CurrentFastMCP - -mcp = FastMCP("Demo") - - -@mcp.tool -async def server_info(server: FastMCP = CurrentFastMCP()) -> str: - return f"Server: {server.name}" -``` - -**Function:** Use `get_server()`: - -```python -from fastmcp.server.dependencies import get_server - -def get_server_name() -> str: - return get_server().name -``` - -### HTTP Request - -<VersionBadge version="2.2.11" /> - -Access the Starlette Request when running over HTTP transports (SSE or Streamable HTTP). - -**Dependency injection:** Use `CurrentRequest()`: - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import CurrentRequest -from starlette.requests import Request - -mcp = FastMCP("Demo") - - -@mcp.tool -async def client_info(request: Request = CurrentRequest()) -> dict: - return { - "user_agent": request.headers.get("user-agent", "Unknown"), - "client_ip": request.client.host if request.client else "Unknown", - } -``` - -**Function:** Use `get_http_request()`: - -```python -from fastmcp.server.dependencies import get_http_request - -def get_client_ip() -> str: - request = get_http_request() - return request.client.host if request.client else "Unknown" -``` - -<Note> -Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport). -For background tasks created from an HTTP request, FastMCP restores a minimal request -backed by the originating request's snapshotted headers. Use HTTP Headers if you need -graceful fallback. -</Note> - -### HTTP Headers - -<VersionBadge version="2.2.11" /> - -Access HTTP headers with graceful fallback. When a background task originates from an -HTTP request, FastMCP restores the originating headers inside the worker. When no HTTP -request is available, this returns an empty dictionary, making it safe for code that -might run over any transport. - -**Dependency injection:** Use `CurrentHeaders()`: - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import CurrentHeaders - -mcp = FastMCP("Demo") - - -@mcp.tool -async def get_auth_type(headers: dict = CurrentHeaders()) -> str: - auth = headers.get("authorization", "") - return "Bearer" if auth.startswith("Bearer ") else "None" -``` - -**Function:** Use `get_http_headers()`: - -```python -from fastmcp.server.dependencies import get_http_headers - -def get_user_agent() -> str: - headers = get_http_headers() - return headers.get("user-agent", "Unknown") -``` - -By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers. - -### Access Token - -<VersionBadge version="2.11.0" /> - -Access the authenticated user's token when your server uses authentication. - -**Dependency injection:** Use `CurrentAccessToken()` (raises if not authenticated): - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import CurrentAccessToken -from fastmcp.server.auth import AccessToken - -mcp = FastMCP("Demo") - - -@mcp.tool -async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: - return token.claims.get("sub", "unknown") -``` - -**Function:** Use `get_access_token()` (returns `None` if not authenticated): - -```python -from fastmcp.server.dependencies import get_access_token - -@mcp.tool -async def get_user_info() -> dict: - token = get_access_token() - if token is None: - return {"authenticated": False} - return {"authenticated": True, "user": token.claims.get("sub")} -``` - -The `AccessToken` object provides: - -- **`client_id`**: The OAuth client identifier -- **`scopes`**: List of granted permission scopes -- **`expires_at`**: Token expiration timestamp (if available) -- **`claims`**: Dictionary of all token claims (JWT claims or provider-specific data) - -### Token Claims - -When you need just one specific value from the token—like a user ID or tenant identifier—`TokenClaim()` extracts it directly without needing the full token object. - -```python -from fastmcp import FastMCP -from fastmcp.server.dependencies import TokenClaim - -mcp = FastMCP("Demo") - - -@mcp.tool -async def add_expense( - amount: float, - user_id: str = TokenClaim("oid"), # Azure object ID -) -> dict: - await db.insert({"user_id": user_id, "amount": amount}) - return {"status": "created", "user_id": user_id} -``` - -`TokenClaim()` raises a `RuntimeError` if the claim doesn't exist, listing available claims to help with debugging. - -Common claims vary by identity provider: - -| Provider | User ID Claim | Email Claim | Name Claim | -|----------|--------------|-------------|------------| -| Azure/Entra | `oid` | `email` | `name` | -| GitHub | `sub` | `email` | `name` | -| Google | `sub` | `email` | `name` | -| Auth0 | `sub` | `email` | `name` | - -### Background Task Dependencies - -<VersionBadge version="2.3.0" /> - -For background task execution, FastMCP provides dependencies that integrate with [Docket](https://github.com/chrisguidry/docket). These require installing `fastmcp[tasks]`. - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import CurrentDocket, CurrentWorker, Progress - -mcp = FastMCP("Task Demo") - - -@mcp.tool(task=True) -async def long_running_task( - data: str, - docket=CurrentDocket(), - worker=CurrentWorker(), - progress=Progress(), -) -> str: - await progress.set_total(100) - - for i in range(100): - # Process chunk... - await progress.increment() - await progress.set_message(f"Processing chunk {i + 1}") - - return "Complete" -``` - -- **`CurrentDocket()`**: Access the Docket instance for scheduling additional background work -- **`CurrentWorker()`**: Access the worker processing tasks (name, concurrency settings) -- **`Progress()`**: Track task progress with atomic updates - -<Note> -Task dependencies require `pip install 'fastmcp[tasks]'`. They're only available within task-enabled components (`task=True`). For comprehensive task patterns, see the [Docket documentation](https://chrisguidry.github.io/docket/dependencies/). -</Note> - -## Custom Dependencies - -Beyond the built-in dependencies, you can create your own to inject configuration, database connections, API clients, or any other values your functions need. - -### Using Depends() - -The `Depends()` function wraps any callable and injects its return value. This works with synchronous functions, async functions, and async context managers. - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import Depends - -mcp = FastMCP("Custom Deps Demo") - - -def get_config() -> dict: - return {"api_url": "https://api.example.com", "timeout": 30} - - -async def get_user_id() -> int: - # Could fetch from database, external service, etc. - return 42 - - -@mcp.tool -async def fetch_data( - query: str, - config: dict = Depends(get_config), - user_id: int = Depends(get_user_id), -) -> str: - return f"User {user_id} fetching '{query}' from {config['api_url']}" -``` - -### Caching - -Dependencies are cached per-request. If multiple parameters use the same dependency, or if nested dependencies share a common dependency, it's resolved once and the same instance is reused. - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import Depends - -mcp = FastMCP("Caching Demo") - - -def get_db_connection(): - print("Connecting to database...") # Only printed once per request - return {"connection": "active"} - - -def get_user_repo(db=Depends(get_db_connection)): - return {"db": db, "type": "user"} - - -def get_order_repo(db=Depends(get_db_connection)): - return {"db": db, "type": "order"} - - -@mcp.tool -async def process_order( - order_id: str, - users=Depends(get_user_repo), - orders=Depends(get_order_repo), -) -> str: - # Both repos share the same db connection - return f"Processed order {order_id}" -``` - -### Resource Management - -For dependencies that need cleanup—database connections, file handles, HTTP clients—use an async context manager. The cleanup code runs after your function completes, even if an error occurs. - -```python -from contextlib import asynccontextmanager - -from fastmcp import FastMCP -from fastmcp.dependencies import Depends - -mcp = FastMCP("Resource Demo") - - -@asynccontextmanager -async def get_database(): - db = await connect_to_database() - try: - yield db - finally: - await db.close() - - -@mcp.tool -async def query_users(sql: str, db=Depends(get_database)) -> list: - return await db.execute(sql) -``` - -### Nested Dependencies - -Dependencies can depend on other dependencies. FastMCP resolves them in the correct order and applies caching across the dependency tree. - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import Depends - -mcp = FastMCP("Nested Demo") - - -def get_base_url() -> str: - return "https://api.example.com" - - -def get_api_client(base_url: str = Depends(get_base_url)) -> dict: - return {"base_url": base_url, "version": "v1"} - - -@mcp.tool -async def call_api(endpoint: str, client: dict = Depends(get_api_client)) -> str: - return f"Calling {client['base_url']}/{client['version']}/{endpoint}" -``` - -For advanced dependency patterns—like `TaskArgument()` for accessing task parameters, or custom `Dependency` subclasses—see the [Docket dependency documentation](https://chrisguidry.github.io/docket/dependencies/). diff --git a/docs/v3/servers/elicitation.mdx b/docs/v3/servers/elicitation.mdx deleted file mode 100644 index 923e704c6..000000000 --- a/docs/v3/servers/elicitation.mdx +++ /dev/null @@ -1,379 +0,0 @@ ---- -title: User Elicitation -sidebarTitle: Elicitation -description: Request structured input from users during tool execution through the MCP context. -icon: message-question ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.10.0" /> - -User elicitation allows MCP servers to request structured input from users during tool execution. Instead of requiring all inputs upfront, tools can interactively ask for missing parameters, clarification, or additional context as needed. - -Elicitation enables tools to pause execution and request specific information from users: - -- **Missing parameters**: Ask for required information not provided initially -- **Clarification requests**: Get user confirmation or choices for ambiguous scenarios -- **Progressive disclosure**: Collect complex information step-by-step -- **Dynamic workflows**: Adapt tool behavior based on user responses - -For example, a file management tool might ask "Which directory should I create?" or a data analysis tool might request "What date range should I analyze?" - -## Overview - -Use the `ctx.elicit()` method within any tool function to request user input. Specify the message to display and the type of response you expect. - -```python -from fastmcp import FastMCP, Context -from dataclasses import dataclass - -mcp = FastMCP("Elicitation Server") - -@dataclass -class UserInfo: - name: str - age: int - -@mcp.tool -async def collect_user_info(ctx: Context) -> str: - """Collect user information through interactive prompts.""" - result = await ctx.elicit( - message="Please provide your information", - response_type=UserInfo - ) - - if result.action == "accept": - user = result.data - return f"Hello {user.name}, you are {user.age} years old" - elif result.action == "decline": - return "Information not provided" - else: # cancel - return "Operation cancelled" -``` - -The elicitation result contains an `action` field indicating how the user responded: - -| Action | Description | -|--------|-------------| -| `accept` | User provided valid input—data is available in the `data` field | -| `decline` | User chose not to provide the requested information | -| `cancel` | User cancelled the entire operation | - -FastMCP also provides typed result classes for pattern matching: - -```python -from fastmcp.server.elicitation import ( - AcceptedElicitation, - DeclinedElicitation, - CancelledElicitation, -) - -@mcp.tool -async def pattern_example(ctx: Context) -> str: - result = await ctx.elicit("Enter your name:", response_type=str) - - match result: - case AcceptedElicitation(data=name): - return f"Hello {name}!" - case DeclinedElicitation(): - return "No name provided" - case CancelledElicitation(): - return "Operation cancelled" -``` - -### Multi-Turn Elicitation - -Tools can make multiple elicitation calls to gather information progressively: - -```python -@mcp.tool -async def plan_meeting(ctx: Context) -> str: - """Plan a meeting by gathering details step by step.""" - - title_result = await ctx.elicit("What's the meeting title?", response_type=str) - if title_result.action != "accept": - return "Meeting planning cancelled" - - duration_result = await ctx.elicit("Duration in minutes?", response_type=int) - if duration_result.action != "accept": - return "Meeting planning cancelled" - - priority_result = await ctx.elicit( - "Is this urgent?", - response_type=["yes", "no"] - ) - if priority_result.action != "accept": - return "Meeting planning cancelled" - - urgent = priority_result.data == "yes" - return f"Meeting '{title_result.data}' for {duration_result.data} minutes (Urgent: {urgent})" -``` - -### Client Requirements - -Elicitation requires the client to implement an elicitation handler. If a client doesn't support elicitation, calls to `ctx.elicit()` will raise an error indicating that elicitation is not supported. - -See [Client Elicitation](/clients/elicitation) for details on how clients handle these requests. - -## Schema and Response Types - -The server must send a schema to the client indicating the type of data it expects in response to the elicitation request. The MCP spec only supports a limited subset of JSON Schema types for elicitation responses—specifically JSON **objects** with **primitive** properties including `string`, `number` (or `integer`), `boolean`, and `enum` fields. - -FastMCP makes it easy to request a broader range of types, including scalars (e.g. `str`) or no response at all, by automatically wrapping them in MCP-compatible object schemas. - -### Scalar Types - -You can request simple scalar data types for basic input, such as a string, integer, or boolean. When you request a scalar type, FastMCP automatically wraps it in an object schema for MCP spec compatibility. Clients will see a schema requesting a single "value" field of the requested type. Once clients respond, the provided object is "unwrapped" and the scalar value is returned directly in the `data` field. - -<CodeGroup> -```python title="String" -@mcp.tool -async def get_user_name(ctx: Context) -> str: - result = await ctx.elicit("What's your name?", response_type=str) - - if result.action == "accept": - return f"Hello, {result.data}!" - return "No name provided" -``` -```python title="Integer" -@mcp.tool -async def pick_a_number(ctx: Context) -> str: - result = await ctx.elicit("Pick a number!", response_type=int) - - if result.action == "accept": - return f"You picked {result.data}" - return "No number provided" -``` -```python title="Boolean" -@mcp.tool -async def pick_a_boolean(ctx: Context) -> str: - result = await ctx.elicit("True or false?", response_type=bool) - - if result.action == "accept": - return f"You picked {result.data}" - return "No boolean provided" -``` -</CodeGroup> - -#### Customizing the Field Label - -<VersionBadge version="3.3.0" /> - -When FastMCP wraps a scalar, `Literal`, `Enum`, or one of the constrained-option shorthands, the wrapper's `value` property is labelled `"Value"` by default — and some clients (including VS Code) render that label directly in the UI. Pass `response_title` and `response_description` to override it: - -```python -@mcp.tool -async def confirm_purchase(ctx: Context) -> str: - result = await ctx.elicit( - "Buy 1x Baguette?", - response_type=bool, - response_title="Confirm purchase", - response_description="Approve this transaction?", - ) - if result.action == "accept": - return "Purchased" if result.data else "Declined" - return "No response" -``` - -These arguments only apply when FastMCP is adding the wrapper. For structured responses (`BaseModel`, dataclass, `TypedDict`), set the metadata on the individual fields via `Field(title=..., description=...)` — passing `response_title` or `response_description` alongside a model type raises `TypeError`. - -### No Response - -Sometimes, the goal of an elicitation is to simply get a user to approve or reject an action. Pass `None` as the response type to indicate that no data is expected. The `data` field will be `None` when the user accepts. - -```python -@mcp.tool -async def approve_action(ctx: Context) -> str: - result = await ctx.elicit("Approve this action?", response_type=None) - - if result.action == "accept": - return do_action() - else: - raise ValueError("Action rejected") -``` - -### Constrained Options - -Constrain the user's response to a specific set of values using a `Literal` type, Python enum, or a list of strings as a convenient shortcut. - -<CodeGroup> -```python title="List of strings" -@mcp.tool -async def set_priority(ctx: Context) -> str: - result = await ctx.elicit( - "What priority level?", - response_type=["low", "medium", "high"], - ) - - if result.action == "accept": - return f"Priority set to: {result.data}" -``` -```python title="Literal type" -from typing import Literal - -@mcp.tool -async def set_priority(ctx: Context) -> str: - result = await ctx.elicit( - "What priority level?", - response_type=Literal["low", "medium", "high"] - ) - - if result.action == "accept": - return f"Priority set to: {result.data}" - return "No priority set" -``` -```python title="Python enum" -from enum import Enum - -class Priority(Enum): - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - -@mcp.tool -async def set_priority(ctx: Context) -> str: - result = await ctx.elicit("What priority level?", response_type=Priority) - - if result.action == "accept": - return f"Priority set to: {result.data.value}" - return "No priority set" -``` -</CodeGroup> - -### Multi-Select - -<VersionBadge version="2.14.0" /> - -Enable multi-select by wrapping your choices in an additional list level. This allows users to select multiple values from the available options. - -<CodeGroup> -```python title="List of strings" -@mcp.tool -async def select_tags(ctx: Context) -> str: - result = await ctx.elicit( - "Choose tags", - response_type=[["bug", "feature", "documentation"]] # Note: list of a list - ) - - if result.action == "accept": - tags = result.data - return f"Selected tags: {', '.join(tags)}" -``` -```python title="list[Enum] type" -from enum import Enum - -class Tag(Enum): - BUG = "bug" - FEATURE = "feature" - DOCS = "documentation" - -@mcp.tool -async def select_tags(ctx: Context) -> str: - result = await ctx.elicit( - "Choose tags", - response_type=list[Tag] - ) - if result.action == "accept": - tags = [tag.value for tag in result.data] - return f"Selected: {', '.join(tags)}" -``` -</CodeGroup> - -### Titled Options - -<VersionBadge version="2.14.0" /> - -For better UI display, provide human-readable titles for enum options. FastMCP generates SEP-1330 compliant schemas using the `oneOf` pattern with `const` and `title` fields. - -```python -@mcp.tool -async def set_priority(ctx: Context) -> str: - result = await ctx.elicit( - "What priority level?", - response_type={ - "low": {"title": "Low Priority"}, - "medium": {"title": "Medium Priority"}, - "high": {"title": "High Priority"} - } - ) - - if result.action == "accept": - return f"Priority set to: {result.data}" -``` - -For multi-select with titles, wrap the dict in a list: - -```python -@mcp.tool -async def select_priorities(ctx: Context) -> str: - result = await ctx.elicit( - "Choose priorities", - response_type=[{ - "low": {"title": "Low Priority"}, - "medium": {"title": "Medium Priority"}, - "high": {"title": "High Priority"} - }] - ) - - if result.action == "accept": - return f"Selected: {', '.join(result.data)}" -``` - -### Structured Responses - -Request structured data with multiple fields by using a dataclass, typed dict, or Pydantic model as the response type. Note that the MCP spec only supports shallow objects with scalar (string, number, boolean) or enum properties. - -```python -from dataclasses import dataclass -from typing import Literal - -@dataclass -class TaskDetails: - title: str - description: str - priority: Literal["low", "medium", "high"] - due_date: str - -@mcp.tool -async def create_task(ctx: Context) -> str: - result = await ctx.elicit( - "Please provide task details", - response_type=TaskDetails - ) - - if result.action == "accept": - task = result.data - return f"Created task: {task.title} (Priority: {task.priority})" - return "Task creation cancelled" -``` - -### Default Values - -<VersionBadge version="2.14.0" /> - -Provide default values for elicitation fields using Pydantic's `Field(default=...)`. Clients will pre-populate form fields with these defaults. Fields with default values are automatically marked as optional. - -```python -from pydantic import BaseModel, Field -from enum import Enum - -class Priority(Enum): - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - -class TaskDetails(BaseModel): - title: str = Field(description="Task title") - description: str = Field(default="", description="Task description") - priority: Priority = Field(default=Priority.MEDIUM, description="Task priority") - -@mcp.tool -async def create_task(ctx: Context) -> str: - result = await ctx.elicit("Please provide task details", response_type=TaskDetails) - if result.action == "accept": - return f"Created: {result.data.title}" - return "Task creation cancelled" -``` - -Default values are supported for strings, integers, numbers, booleans, and enums. diff --git a/docs/v3/servers/icons.mdx b/docs/v3/servers/icons.mdx deleted file mode 100644 index c9b558094..000000000 --- a/docs/v3/servers/icons.mdx +++ /dev/null @@ -1,151 +0,0 @@ ---- -title: Icons -description: Add visual icons to your servers, tools, resources, and prompts -icon: image ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.13.0" /> - -Icons provide visual representations for your MCP servers and components, helping client applications present better user interfaces. When displayed in MCP clients, icons help users quickly identify and navigate your server's capabilities. - -## Icon Format - -Icons use the standard MCP Icon type from the MCP protocol specification. Each icon specifies a source URL or data URI, and optionally includes MIME type and size information. - -```python -from mcp.types import Icon - -icon = Icon( - src="https://example.com/icon.png", - mimeType="image/png", - sizes=["48x48"] -) -``` - -The fields serve different purposes: - -- **src**: URL or data URI pointing to the icon image -- **mimeType** (optional): MIME type of the image (e.g., "image/png", "image/svg+xml") -- **sizes** (optional): Array of size descriptors (e.g., ["48x48"], ["any"]) - -## Server Icons - -Add icons and a website URL to your server for display in client applications. Multiple icons at different sizes help clients choose the best resolution for their display context. - -```python -from fastmcp import FastMCP -from mcp.types import Icon - -mcp = FastMCP( - name="WeatherService", - website_url="https://weather.example.com", - icons=[ - Icon( - src="https://weather.example.com/icon-48.png", - mimeType="image/png", - sizes=["48x48"] - ), - Icon( - src="https://weather.example.com/icon-96.png", - mimeType="image/png", - sizes=["96x96"] - ), - ] -) -``` - -Server icons appear in MCP client interfaces to help users identify your server among others they may have installed. - -## Component Icons - -Icons can be added to individual tools, resources, resource templates, and prompts. This helps users visually distinguish between different component types and purposes. - -### Tool Icons - -```python -from mcp.types import Icon - -@mcp.tool( - icons=[Icon(src="https://example.com/calculator-icon.png")] -) -def calculate_sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b -``` - -### Resource Icons - -```python -@mcp.resource( - "config://settings", - icons=[Icon(src="https://example.com/config-icon.png")] -) -def get_settings() -> dict: - """Retrieve application settings.""" - return {"theme": "dark", "language": "en"} -``` - -### Resource Template Icons - -```python -@mcp.resource( - "user://{user_id}/profile", - icons=[Icon(src="https://example.com/user-icon.png")] -) -def get_user_profile(user_id: str) -> dict: - """Get a user's profile.""" - return {"id": user_id, "name": f"User {user_id}"} -``` - -### Prompt Icons - -```python -@mcp.prompt( - icons=[Icon(src="https://example.com/prompt-icon.png")] -) -def analyze_code(code: str): - """Create a prompt for code analysis.""" - return f"Please analyze this code:\n\n{code}" -``` - -## Using Data URIs - -For small icons or when you want to embed the icon directly without external dependencies, use data URIs. This approach eliminates the need for hosting and ensures the icon is always available. - -```python -from mcp.types import Icon -from fastmcp.utilities.types import Image - -# SVG icon as data URI -svg_icon = Icon( - src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6Ii8+PC9zdmc+", - mimeType="image/svg+xml" -) - -@mcp.tool(icons=[svg_icon]) -def my_tool() -> str: - """A tool with an embedded SVG icon.""" - return "result" -``` - -### Generating Data URIs from Files - -FastMCP provides the `Image` utility class to convert local image files into data URIs. - -```python -from mcp.types import Icon -from fastmcp.utilities.types import Image - -# Generate a data URI from a local image file -img = Image(path="./assets/brand/favicon.png") -icon = Icon(src=img.to_data_uri()) - -@mcp.tool(icons=[icon]) -def file_icon_tool() -> str: - """A tool with an icon generated from a local file.""" - return "result" -``` - -This approach is useful when you have local image assets and want to embed them directly in your server definition. diff --git a/docs/v3/servers/lifespan.mdx b/docs/v3/servers/lifespan.mdx deleted file mode 100644 index 822e4f8a1..000000000 --- a/docs/v3/servers/lifespan.mdx +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: Lifespans -sidebarTitle: Lifespan -description: Server-level setup and teardown with composable lifespans -icon: heart-pulse -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Lifespans let you run code once when the server starts and clean up when it stops. Unlike per-session handlers, lifespans run exactly once regardless of how many clients connect. - -## Basic Usage - -Use the `@lifespan` decorator to define a lifespan: - -```python -from fastmcp import FastMCP -from fastmcp.server.lifespan import lifespan - -@lifespan -async def app_lifespan(server): - # Setup: runs once when server starts - print("Starting up...") - try: - yield {"started_at": "2024-01-01"} - finally: - # Teardown: runs when server stops - print("Shutting down...") - -mcp = FastMCP("MyServer", lifespan=app_lifespan) -``` - -The dict you yield becomes the **lifespan context**, accessible from tools. - -<Note> -Always use `try/finally` for cleanup code to ensure it runs even if the server is cancelled. -</Note> - -## Accessing Lifespan Context - -Access the lifespan context in tools via `ctx.lifespan_context`: - -```python -from fastmcp import FastMCP, Context -from fastmcp.server.lifespan import lifespan - -@lifespan -async def app_lifespan(server): - # Initialize shared state - data = {"users": ["alice", "bob"]} - yield {"data": data} - -mcp = FastMCP("MyServer", lifespan=app_lifespan) - -@mcp.tool -def list_users(ctx: Context) -> list[str]: - data = ctx.lifespan_context["data"] - return data["users"] -``` - -## Composing Lifespans - -Compose multiple lifespans with the `|` operator: - -```python -from fastmcp import FastMCP -from fastmcp.server.lifespan import lifespan - -@lifespan -async def config_lifespan(server): - config = {"debug": True, "version": "1.0"} - yield {"config": config} - -@lifespan -async def data_lifespan(server): - data = {"items": []} - yield {"data": data} - -# Compose with | -mcp = FastMCP("MyServer", lifespan=config_lifespan | data_lifespan) -``` - -Composed lifespans: -- Enter in order (left to right) -- Exit in reverse order (right to left) -- Merge their context dicts (later values overwrite earlier on conflict) - -## Backwards Compatibility - -Existing `@asynccontextmanager` lifespans still work when passed directly to FastMCP: - -```python -from contextlib import asynccontextmanager -from fastmcp import FastMCP - -@asynccontextmanager -async def legacy_lifespan(server): - yield {"key": "value"} - -mcp = FastMCP("MyServer", lifespan=legacy_lifespan) -``` - -To compose an `@asynccontextmanager` function with `@lifespan` functions, wrap it with `ContextManagerLifespan`: - -```python -from contextlib import asynccontextmanager -from fastmcp.server.lifespan import lifespan, ContextManagerLifespan - -@asynccontextmanager -async def legacy_lifespan(server): - yield {"legacy": True} - -@lifespan -async def new_lifespan(server): - yield {"new": True} - -# Wrap the legacy lifespan explicitly for composition -combined = ContextManagerLifespan(legacy_lifespan) | new_lifespan -``` - -## With FastAPI - -When mounting FastMCP into FastAPI, use `combine_lifespans` to run both your app's lifespan and the MCP server's lifespan: - -```python -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from fastmcp import FastMCP -from fastmcp.utilities.lifespan import combine_lifespans - -@asynccontextmanager -async def app_lifespan(app): - print("FastAPI starting...") - yield - print("FastAPI shutting down...") - -mcp = FastMCP("Tools") -mcp_app = mcp.http_app() - -app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) -app.mount("/mcp", mcp_app) -``` - -See the [FastAPI integration guide](/integrations/fastapi#combining-lifespans) for full details. diff --git a/docs/v3/servers/logging.mdx b/docs/v3/servers/logging.mdx deleted file mode 100644 index e01fdd875..000000000 --- a/docs/v3/servers/logging.mdx +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Client Logging -sidebarTitle: Logging -description: Send log messages back to MCP clients through the context. -icon: receipt ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<Tip> -This documentation covers **MCP client logging**—sending messages from your server to MCP clients. For standard server-side logging (e.g., writing to files, console), use `fastmcp.utilities.logging.get_logger()` or Python's built-in `logging` module. -</Tip> - -Server logging allows MCP tools to send debug, info, warning, and error messages back to the client. Unlike standard Python logging, MCP server logging sends messages directly to the client, making them visible in the client's interface or logs. - -## Basic Usage - -Use the context logging methods within any tool function: - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP("LoggingDemo") - -@mcp.tool -async def analyze_data(data: list[float], ctx: Context) -> dict: - """Analyze numerical data with comprehensive logging.""" - await ctx.debug("Starting analysis of numerical data") - await ctx.info(f"Analyzing {len(data)} data points") - - try: - if not data: - await ctx.warning("Empty data list provided") - return {"error": "Empty data list"} - - result = sum(data) / len(data) - await ctx.info(f"Analysis complete, average: {result}") - return {"average": result, "count": len(data)} - - except Exception as e: - await ctx.error(f"Analysis failed: {str(e)}") - raise -``` - -## Log Levels - -| Level | Use Case | -|-------|----------| -| `ctx.debug()` | Detailed execution information for diagnosing problems | -| `ctx.info()` | General information about normal program execution | -| `ctx.warning()` | Potentially harmful situations that don't prevent execution | -| `ctx.error()` | Error events that might still allow the application to continue | - -## Structured Logging - -All logging methods accept an `extra` parameter for sending structured data to the client. This is useful for creating rich, queryable logs. - -```python -@mcp.tool -async def process_transaction(transaction_id: str, amount: float, ctx: Context): - await ctx.info( - f"Processing transaction {transaction_id}", - extra={ - "transaction_id": transaction_id, - "amount": amount, - "currency": "USD" - } - ) -``` - -## Server-Side Logs - -Messages sent to clients via `ctx.log()` and its convenience methods are also logged to the server's log at `DEBUG` level. Enable debug logging on the `fastmcp.server.context.to_client` logger to see these messages: - -```python -import logging -from fastmcp.utilities.logging import get_logger - -to_client_logger = get_logger(name="fastmcp.server.context.to_client") -to_client_logger.setLevel(level=logging.DEBUG) -``` - -## Client Handling - -Log messages are sent to the client through the MCP protocol. How clients handle these messages depends on their implementation—development clients may display logs in real-time, production clients may store them for analysis, and integration clients may forward them to external logging systems. - -See [Client Logging](/clients/logging) for details on how clients handle server log messages. diff --git a/docs/v3/servers/middleware.mdx b/docs/v3/servers/middleware.mdx deleted file mode 100644 index b974bb0f5..000000000 --- a/docs/v3/servers/middleware.mdx +++ /dev/null @@ -1,959 +0,0 @@ ---- -title: Middleware -sidebarTitle: Middleware -description: Add cross-cutting functionality to your MCP server with middleware that intercepts and modifies requests and responses. -icon: layer-group ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.9.0" /> - -Middleware adds behavior that applies across multiple operations—authentication, logging, rate limiting, or request transformation—without modifying individual tools or resources. - -<Tip> -MCP middleware is a FastMCP-specific concept and is not part of the official MCP protocol specification. -</Tip> - -## Overview - -MCP middleware forms a pipeline around your server's operations. When a request arrives, it flows through each middleware in order—each can inspect, modify, or reject the request before passing it along. After the operation completes, the response flows back through the same middleware in reverse order. - -``` -Request → Middleware A → Middleware B → Handler → Middleware B → Middleware A → Response -``` - -This bidirectional flow means middleware can: -- **Pre-process**: Validate authentication, log incoming requests, check rate limits -- **Post-process**: Transform responses, record timing metrics, handle errors consistently - -The key decision point is `call_next(context)`. Calling it continues the chain; not calling it stops processing entirely. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware import Middleware, MiddlewareContext - -class LoggingMiddleware(Middleware): - async def on_message(self, context: MiddlewareContext, call_next): - print(f"→ {context.method}") - result = await call_next(context) - print(f"← {context.method}") - return result - -mcp = FastMCP("MyServer") -mcp.add_middleware(LoggingMiddleware()) -``` - -### Execution Order - -Middleware executes in the order added to the server. The first middleware runs first on the way in and last on the way out: - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware -from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware -from fastmcp.server.middleware.logging import LoggingMiddleware - -mcp = FastMCP("MyServer") -mcp.add_middleware(ErrorHandlingMiddleware()) # 1st in, last out -mcp.add_middleware(RateLimitingMiddleware()) # 2nd in, 2nd out -mcp.add_middleware(LoggingMiddleware()) # 3rd in, first out -``` - -This ordering matters. Place error handling early so it catches exceptions from all subsequent middleware. Place logging late so it records the actual execution after other middleware has processed the request. - -### Server Composition - -When using [mounted servers](/servers/composition), middleware behavior follows a clear hierarchy: - -- **Parent middleware** runs for all requests, including those routed to mounted servers -- **Mounted server middleware** only runs for requests handled by that specific server - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.logging import LoggingMiddleware - -parent = FastMCP("Parent") -parent.add_middleware(AuthMiddleware()) # Runs for ALL requests - -child = FastMCP("Child") -child.add_middleware(LoggingMiddleware()) # Only runs for child's tools - -parent.mount(child, namespace="child") -``` - -Requests to `child_tool` flow through the parent's `AuthMiddleware` first, then through the child's `LoggingMiddleware`. - -Middleware-stored state does not automatically cross mount boundaries. If `AuthMiddleware` on the parent calls `ctx.set_state("user_id", ...)`, a tool on the child server calling `ctx.get_state("user_id")` will get `None` — each `FastMCP` instance owns its own session state store. To share state across the mount, either pass the same `session_state_store` to both servers or use `serializable=False` for request-scoped values. See [State and Mounted Servers](/servers/context#state-and-mounted-servers) for details. - -## Hooks - -Rather than processing every message identically, FastMCP provides specialized hooks at different levels of specificity. Multiple hooks fire for a single request, going from general to specific: - -| Level | Hooks | Purpose | -|-------|-------|---------| -| Message | `on_message` | All MCP traffic (requests and notifications) | -| Type | `on_request`, `on_notification` | Requests expecting responses vs fire-and-forget | -| Operation | `on_call_tool`, `on_read_resource`, `on_get_prompt`, etc. | Specific MCP operations | - -When a client calls a tool, the middleware chain processes `on_message` first, then `on_request`, then `on_call_tool`. This hierarchy lets you target exactly the right scope—use `on_message` for logging everything, `on_request` for authentication, and `on_call_tool` for tool-specific behavior. - -### Hook Signature - -Every hook follows the same pattern: - -```python -async def hook_name(self, context: MiddlewareContext, call_next) -> result_type: - # Pre-processing - result = await call_next(context) - # Post-processing - return result -``` - -**Parameters:** -- `context` — `MiddlewareContext` containing request information -- `call_next` — Async function to continue the middleware chain - -**Returns:** The appropriate result type for the hook (varies by operation). - -### MiddlewareContext - -The `context` parameter provides access to request details: - -| Attribute | Type | Description | -|-----------|------|-------------| -| `method` | `str` | MCP method name (e.g., `"tools/call"`) | -| `source` | `str` | Origin: `"client"` or `"server"` | -| `type` | `str` | Message type: `"request"` or `"notification"` | -| `message` | `object` | The MCP message data | -| `timestamp` | `datetime` | When the request was received | -| `fastmcp_context` | `Context` | FastMCP context object (if available) | - -### Message Hooks - -#### on_message - -Called for every MCP message—both requests and notifications. - -```python -async def on_message(self, context: MiddlewareContext, call_next): - result = await call_next(context) - return result -``` - -Use for: Logging, metrics, or any cross-cutting concern that applies to all traffic. - -#### on_request - -Called for MCP requests that expect a response. - -```python -async def on_request(self, context: MiddlewareContext, call_next): - result = await call_next(context) - return result -``` - -Use for: Authentication, authorization, request validation. - -#### on_notification - -Called for fire-and-forget MCP notifications. - -```python -async def on_notification(self, context: MiddlewareContext, call_next): - await call_next(context) - # Notifications don't return values -``` - -Use for: Event logging, async side effects. - -### Operation Hooks - -#### on_call_tool - -Called when a tool is executed. The `context.message` contains `name` (tool name) and `arguments` (dict). - -```python -async def on_call_tool(self, context: MiddlewareContext, call_next): - tool_name = context.message.name - args = context.message.arguments - result = await call_next(context) - return result -``` - -**Returns:** Tool execution result or raises `ToolError`. - -#### on_read_resource - -Called when a resource is read. The `context.message` contains `uri` (resource URI). - -```python -async def on_read_resource(self, context: MiddlewareContext, call_next): - uri = context.message.uri - result = await call_next(context) - return result -``` - -**Returns:** Resource content. - -#### on_get_prompt - -Called when a prompt is retrieved. The `context.message` contains `name` (prompt name) and `arguments` (dict). - -```python -async def on_get_prompt(self, context: MiddlewareContext, call_next): - prompt_name = context.message.name - result = await call_next(context) - return result -``` - -**Returns:** Prompt messages. - -#### on_list_tools - -Called when listing available tools. Returns a list of FastMCP `Tool` objects before MCP conversion. - -```python -async def on_list_tools(self, context: MiddlewareContext, call_next): - tools = await call_next(context) - # Filter or modify the tool list - return tools -``` - -**Returns:** `list[Tool]` — Can be filtered before returning to client. - -#### on_list_resources - -Called when listing available resources. Returns FastMCP `Resource` objects. - -```python -async def on_list_resources(self, context: MiddlewareContext, call_next): - resources = await call_next(context) - return resources -``` - -**Returns:** `list[Resource]` - -#### on_list_resource_templates - -Called when listing resource templates. - -```python -async def on_list_resource_templates(self, context: MiddlewareContext, call_next): - templates = await call_next(context) - return templates -``` - -**Returns:** `list[ResourceTemplate]` - -#### on_list_prompts - -Called when listing available prompts. - -```python -async def on_list_prompts(self, context: MiddlewareContext, call_next): - prompts = await call_next(context) - return prompts -``` - -**Returns:** `list[Prompt]` - -#### on_initialize - -<VersionBadge version="2.13.0" /> - -Called when a client connects and initializes the session. This hook cannot modify the initialization response. - -```python -from mcp import McpError -from mcp.types import ErrorData - -async def on_initialize(self, context: MiddlewareContext, call_next): - client_info = context.message.params.get("clientInfo", {}) - client_name = client_info.get("name", "unknown") - - # Reject before call_next to send error to client - if client_name == "blocked-client": - raise McpError(ErrorData(code=-32000, message="Client not supported")) - - await call_next(context) - print(f"Client {client_name} initialized") -``` - -**Returns:** `None` — The initialization response is handled internally by the MCP protocol. - -<Warning> -Raising `McpError` after `call_next()` will only log the error, not send it to the client. The response has already been sent. Always reject **before** `call_next()`. -</Warning> - -### Raw Handler - -For complete control over all messages, override `__call__` instead of individual hooks: - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext - -class RawMiddleware(Middleware): - async def __call__(self, context: MiddlewareContext, call_next): - print(f"Processing: {context.method}") - result = await call_next(context) - print(f"Completed: {context.method}") - return result -``` - -This bypasses the hook dispatch system entirely. Use when you need uniform handling regardless of message type. - -### Session Availability - -<VersionBadge version="2.13.1" /> - -The MCP session may not be available during certain phases like initialization. Check before accessing session-specific attributes: - -```python -async def on_request(self, context: MiddlewareContext, call_next): - ctx = context.fastmcp_context - - if ctx.request_context: - # MCP session available - session_id = ctx.session_id - request_id = ctx.request_id - else: - # Session not yet established (e.g., during initialization) - # Use HTTP helpers if needed - from fastmcp.server.dependencies import get_http_headers - headers = get_http_headers() - - return await call_next(context) -``` - -For HTTP-specific data (headers, client IP) when using HTTP transports, see [HTTP Requests](/servers/context#http-requests). - -## Built-in Middleware - -FastMCP includes production-ready middleware for common server concerns. - -### Logging - -```python -from fastmcp.server.middleware.logging import LoggingMiddleware, StructuredLoggingMiddleware -``` - -`LoggingMiddleware` provides human-readable request and response logging. `StructuredLoggingMiddleware` outputs JSON-formatted logs for aggregation tools like Datadog or Splunk. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.logging import LoggingMiddleware - -mcp = FastMCP("MyServer") -mcp.add_middleware(LoggingMiddleware( - include_payloads=True, - max_payload_length=1000 -)) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `include_payloads` | `bool` | `False` | Log request/response content | -| `max_payload_length` | `int` | `500` | Truncate payloads beyond this length | -| `logger` | `Logger` | module logger | Custom logger instance | - -### Timing - -```python -from fastmcp.server.middleware.timing import TimingMiddleware, DetailedTimingMiddleware -``` - -`TimingMiddleware` logs execution duration for all requests. `DetailedTimingMiddleware` provides per-operation timing with separate tracking for tools, resources, and prompts. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.timing import TimingMiddleware - -mcp = FastMCP("MyServer") -mcp.add_middleware(TimingMiddleware()) -``` - -### Caching - -```python -from fastmcp.server.middleware.caching import ResponseCachingMiddleware -``` - -Caches tool calls, resource reads, and list operations with TTL-based expiration. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.caching import ResponseCachingMiddleware - -mcp = FastMCP("MyServer") -mcp.add_middleware(ResponseCachingMiddleware()) -``` - -Each operation type can be configured independently using settings classes: - -```python -from fastmcp.server.middleware.caching import ( - ResponseCachingMiddleware, - CallToolSettings, - ListToolsSettings, - ReadResourceSettings -) - -mcp.add_middleware(ResponseCachingMiddleware( - list_tools_settings=ListToolsSettings(ttl=30), - call_tool_settings=CallToolSettings(included_tools=["expensive_tool"]), - read_resource_settings=ReadResourceSettings(enabled=False) -)) -``` - -| Settings Class | Configures | -|----------------|------------| -| `ListToolsSettings` | `on_list_tools` caching | -| `CallToolSettings` | `on_call_tool` caching | -| `ListResourcesSettings` | `on_list_resources` caching | -| `ReadResourceSettings` | `on_read_resource` caching | -| `ListPromptsSettings` | `on_list_prompts` caching | -| `GetPromptSettings` | `on_get_prompt` caching | - -Each settings class accepts: -- `enabled` — Enable/disable caching for this operation -- `ttl` — Time-to-live in seconds -- `included_*` / `excluded_*` — Whitelist or blacklist specific items - -For persistence or distributed deployments, configure a different storage backend: - -```python -from pathlib import Path -from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from key_value.aio.stores.filetree import ( - FileTreeStore, - FileTreeV1KeySanitizationStrategy, - FileTreeV1CollectionSanitizationStrategy, -) - -cache_dir = Path("cache") -mcp.add_middleware(ResponseCachingMiddleware( - cache_storage=FileTreeStore( - data_directory=cache_dir, - key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(cache_dir), - collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(cache_dir), - ) -)) -``` - -See [Storage Backends](/servers/storage-backends) for complete options. - -<Note> -Cache keys are based on the operation name and arguments only — they do not include user or session identity. If your tools return user-specific data derived from auth context (e.g., headers or session state) rather than from the request arguments, you should either disable caching for those tools or ensure user identity is part of the tool arguments. -</Note> - -### Rate Limiting - -```python -from fastmcp.server.middleware.rate_limiting import ( - RateLimitingMiddleware, - SlidingWindowRateLimitingMiddleware -) -``` - -`RateLimitingMiddleware` uses a token bucket algorithm allowing controlled bursts. `SlidingWindowRateLimitingMiddleware` provides precise time-window rate limiting without burst allowance. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware - -mcp = FastMCP("MyServer") -mcp.add_middleware(RateLimitingMiddleware( - max_requests_per_second=10.0, - burst_capacity=20 -)) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `max_requests_per_second` | `float` | `10.0` | Sustained request rate | -| `burst_capacity` | `int` | `20` | Maximum burst size | -| `get_client_id` | `Callable` | `None` | Custom client identification | - -For sliding window rate limiting: - -```python -from fastmcp.server.middleware.rate_limiting import SlidingWindowRateLimitingMiddleware - -mcp.add_middleware(SlidingWindowRateLimitingMiddleware( - max_requests=100, - window_minutes=1 -)) -``` - -### Error Handling - -```python -from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware, RetryMiddleware -``` - -`ErrorHandlingMiddleware` provides centralized error logging and transformation. `RetryMiddleware` automatically retries with exponential backoff for transient failures. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware - -mcp = FastMCP("MyServer") -mcp.add_middleware(ErrorHandlingMiddleware( - include_traceback=True, - transform_errors=True, - error_callback=my_error_callback -)) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `include_traceback` | `bool` | `False` | Include stack traces in logs | -| `transform_errors` | `bool` | `False` | Convert exceptions to MCP errors | -| `error_callback` | `Callable` | `None` | Custom callback on errors | - -For automatic retries: - -```python -from fastmcp.server.middleware.error_handling import RetryMiddleware - -mcp.add_middleware(RetryMiddleware( - max_retries=3, - retry_exceptions=(ConnectionError, TimeoutError) -)) -``` - -### Ping - -<VersionBadge version="3.0.0" /> - -```python -from fastmcp.server.middleware import PingMiddleware -``` - -Keeps long-lived connections alive by sending periodic pings. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware import PingMiddleware - -mcp = FastMCP("MyServer") -mcp.add_middleware(PingMiddleware(interval_ms=5000)) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `interval_ms` | `int` | `30000` | Ping interval in milliseconds | - -The ping task starts on the first message and stops automatically when the session ends. Most useful for stateful HTTP connections; has no effect on stateless connections. - -### Response Limiting - -<VersionBadge version="3.0.0" /> - -```python -from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware -``` - -Large tool responses can overwhelm LLM context windows or cause memory issues. You can add response-limiting middleware to enforce size constraints on tool outputs. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware - -mcp = FastMCP("MyServer") - -# Limit all tool responses to 500KB -mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) - -@mcp.tool -def search(query: str) -> str: - # This could return a very large result - return "x" * 1_000_000 # 1MB response - -# When called, the response will be truncated to ~500KB with: -# "...\n\n[Response truncated due to size limit]" -``` - -When a response exceeds the limit, the middleware extracts all text content, joins it together, truncates to fit within the limit, and returns a single `TextContent` block. For non-text responses, the serialized JSON is used as the text source. - -<Note> -If a tool defines an `output_schema`, truncated responses will no longer conform to that schema — the client will receive a plain `TextContent` block instead of the expected structured output. Keep this in mind when setting size limits for tools with structured responses. -</Note> - -```python -# Limit only specific tools -mcp.add_middleware(ResponseLimitingMiddleware( - max_size=100_000, - tools=["search", "fetch_data"], -)) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `max_size` | `int` | `1_000_000` | Maximum response size in bytes (1MB default) | -| `truncation_suffix` | `str` | `"\n\n[Response truncated due to size limit]"` | Suffix appended to truncated responses | -| `tools` | `list[str] \| None` | `None` | Limit only these tools (None = all tools) | - -### Combining Middleware - -Order matters. Place middleware that should run first (on the way in) earliest: - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware -from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware -from fastmcp.server.middleware.timing import TimingMiddleware -from fastmcp.server.middleware.logging import LoggingMiddleware - -mcp = FastMCP("Production Server") - -mcp.add_middleware(ErrorHandlingMiddleware()) # Catch all errors -mcp.add_middleware(RateLimitingMiddleware(max_requests_per_second=50)) -mcp.add_middleware(TimingMiddleware()) -mcp.add_middleware(LoggingMiddleware()) - -@mcp.tool -def my_tool(data: str) -> str: - return f"Processed: {data}" -``` - -## Custom Middleware - -When the built-in middleware doesn't fit your needs—custom authentication schemes, domain-specific logging, or request transformation—subclass `Middleware` and override the hooks you need. - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware import Middleware, MiddlewareContext - -class CustomMiddleware(Middleware): - async def on_request(self, context: MiddlewareContext, call_next): - # Pre-processing - print(f"→ {context.method}") - - result = await call_next(context) - - # Post-processing - print(f"← {context.method}") - return result - -mcp = FastMCP("MyServer") -mcp.add_middleware(CustomMiddleware()) -``` - -Override only the hooks relevant to your use case. Unoverridden hooks pass through automatically. - -### Denying Requests - -Raise the appropriate error type to stop processing and return an error to the client. - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext -from fastmcp.exceptions import ToolError - -class AuthMiddleware(Middleware): - async def on_call_tool(self, context: MiddlewareContext, call_next): - tool_name = context.message.name - - if tool_name in ["delete_all", "admin_config"]: - raise ToolError("Access denied: requires admin privileges") - - return await call_next(context) -``` - -| Operation | Error Type | -|-----------|------------| -| Tool calls | `ToolError` | -| Resource reads | `ResourceError` | -| Prompt retrieval | `PromptError` | -| General requests | `McpError` | - -Do not return error values or skip `call_next()` to indicate errors—raise exceptions for proper error propagation. - -### Modifying Requests - -Change the message before passing it down the chain. - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext - -class InputSanitizer(Middleware): - async def on_call_tool(self, context: MiddlewareContext, call_next): - if context.message.name == "search": - # Normalize search query - query = context.message.arguments.get("query", "") - context.message.arguments["query"] = query.strip().lower() - - return await call_next(context) -``` - -### Modifying Responses - -Transform results after the handler executes. - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext - -class ResponseEnricher(Middleware): - async def on_call_tool(self, context: MiddlewareContext, call_next): - result = await call_next(context) - - if context.message.name == "get_data" and result.structured_content: - result.structured_content["processed_by"] = "enricher" - - return result -``` - -For more complex tool transformations, consider [Transforms](/servers/transforms/transforms) instead. - -### Filtering Lists - -List operations return FastMCP objects that you can filter before they reach the client. When filtering list results, also block execution in the corresponding operation hook to maintain consistency: - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext -from fastmcp.exceptions import ToolError - -class PrivateToolFilter(Middleware): - async def on_list_tools(self, context: MiddlewareContext, call_next): - tools = await call_next(context) - return [tool for tool in tools if "private" not in tool.tags] - - async def on_call_tool(self, context: MiddlewareContext, call_next): - if context.fastmcp_context: - tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) - if "private" in tool.tags: - raise ToolError("Tool not found") - - return await call_next(context) -``` - -### Accessing Component Metadata - -During execution hooks, component metadata (like tags) isn't directly available. Look up the component through the server: - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext -from fastmcp.exceptions import ToolError - -class TagBasedAuth(Middleware): - async def on_call_tool(self, context: MiddlewareContext, call_next): - if context.fastmcp_context: - try: - tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) - - if "requires-auth" in tool.tags: - # Check authentication here - pass - - except Exception: - pass # Let execution handle missing tools - - return await call_next(context) -``` - -The same pattern works for resources and prompts: - -```python -resource = await context.fastmcp_context.fastmcp.get_resource(context.message.uri) -prompt = await context.fastmcp_context.fastmcp.get_prompt(context.message.name) -``` - -### Storing State - -<VersionBadge version="2.11.0" /> - -Middleware can store state that tools access later through the FastMCP context. - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext - -class UserMiddleware(Middleware): - async def on_request(self, context: MiddlewareContext, call_next): - # Extract user from headers (HTTP transport) - from fastmcp.server.dependencies import get_http_headers - headers = get_http_headers() or {} - user_id = headers.get("x-user-id", "anonymous") - - # Store for tools to access - if context.fastmcp_context: - context.fastmcp_context.set_state("user_id", user_id) - - return await call_next(context) -``` - -Tools retrieve the state: - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP("MyServer") - -@mcp.tool -def get_user_data(ctx: Context) -> str: - user_id = ctx.get_state("user_id") - return f"Data for user: {user_id}" -``` - -See [Context State Management](/servers/context#state-management) for details. - -### Constructor Parameters - -Initialize middleware with configuration: - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext - -class ConfigurableMiddleware(Middleware): - def __init__(self, api_key: str, rate_limit: int = 100): - self.api_key = api_key - self.rate_limit = rate_limit - self.request_counts = {} - - async def on_request(self, context: MiddlewareContext, call_next): - # Use self.api_key, self.rate_limit, etc. - return await call_next(context) - -mcp.add_middleware(ConfigurableMiddleware( - api_key="secret", - rate_limit=50 -)) -``` - -### Error Handling in Custom Middleware - -Wrap `call_next()` to handle errors from downstream middleware and handlers. - -```python -from fastmcp.server.middleware import Middleware, MiddlewareContext - -class ErrorLogger(Middleware): - async def on_request(self, context: MiddlewareContext, call_next): - try: - return await call_next(context) - except Exception as e: - print(f"Error in {context.method}: {type(e).__name__}: {e}") - raise # Re-raise to let error propagate -``` - -Catching and not re-raising suppresses the error entirely. Usually you want to log and re-raise. - -### Audit and Event Records - -A common need is to emit one structured record per tool call — for audit logs, policy decisions, or offline analysis — without wrapping individual tools or storing raw payloads. `on_call_tool` is the right place: it sees the call start, the resolved `ToolResult` (so it can detect empty or error results), the duration, and can deny the call before it runs. - -Use [OpenTelemetry](/servers/telemetry) when the goal is to *export* spans to an observability backend. Reach for a record like this when you want a self-contained, redacted audit trail — or to drive runtime decisions from the result. - -```python -import hashlib -import json -from datetime import datetime - -from fastmcp.server.middleware import Middleware, MiddlewareContext -from fastmcp.exceptions import ToolError - - -def _schema_hash(arguments: dict | None) -> str: - """Stable hash of the argument shape — detects schema drift without storing values.""" - shape = sorted(arguments or {}) - return hashlib.sha256(json.dumps(shape).encode()).hexdigest()[:12] - - -def _redact(arguments: dict | None) -> dict: - """Keep keys, drop values — raw inputs stay out of the default path.""" - return {key: "<redacted>" for key in (arguments or {})} - - -def _call_id(context: MiddlewareContext) -> str | None: - """Request id when an MCP session is active (see Session Availability above).""" - ctx = context.fastmcp_context - if ctx is not None and ctx.request_context: - return ctx.request_id - return None - - -class AuditMiddleware(Middleware): - async def on_call_tool(self, context: MiddlewareContext, call_next): - record = { - "tool": context.message.name, - "call_id": _call_id(context), - "schema_hash": _schema_hash(context.message.arguments), - "arguments": _redact(context.message.arguments), - "received_at": context.timestamp.isoformat(), - } - - try: - result = await call_next(context) - except Exception as exc: - record["status"] = "failed" - record["error"] = type(exc).__name__ - self.emit(record) - raise - - empty = not result.content and result.structured_content is None - record["status"] = "error" if result.is_error else "empty" if empty else "completed" - now = datetime.now(context.timestamp.tzinfo) - record["duration_ms"] = round((now - context.timestamp).total_seconds() * 1000, 2) - self.emit(record) - return result - - def emit(self, record: dict) -> None: - # Swap in your sink: structured logger, queue, audit store, etc. - print(json.dumps(record)) -``` - -Each record carries the fields downstream tools tend to need — tool name, call id, input schema hash, redacted arguments, result class (`completed` / `empty` / `error` / `failed`), and duration — while raw inputs and outputs stay out by default. - -To make this a policy layer, deny inside the same hook before calling `call_next`: - -```python -async def on_call_tool(self, context: MiddlewareContext, call_next): - if not self.is_allowed(context.message.name, context.message.arguments): - self.emit({"tool": context.message.name, "status": "denied", "reason": "policy"}) - raise ToolError("Call blocked by policy") - return await call_next(context) -``` - -### Complete Example - -Authentication middleware checking API keys for specific tools: - -```python -from fastmcp import FastMCP -from fastmcp.server.middleware import Middleware, MiddlewareContext -from fastmcp.server.dependencies import get_http_headers -from fastmcp.exceptions import ToolError - -class ApiKeyAuth(Middleware): - def __init__(self, valid_keys: set[str], protected_tools: set[str]): - self.valid_keys = valid_keys - self.protected_tools = protected_tools - - async def on_call_tool(self, context: MiddlewareContext, call_next): - tool_name = context.message.name - - if tool_name not in self.protected_tools: - return await call_next(context) - - headers = get_http_headers() or {} - api_key = headers.get("x-api-key") - - if api_key not in self.valid_keys: - raise ToolError(f"Invalid API key for protected tool: {tool_name}") - - return await call_next(context) - -mcp = FastMCP("Secure Server") -mcp.add_middleware(ApiKeyAuth( - valid_keys={"key-1", "key-2"}, - protected_tools={"delete_user", "admin_panel"} -)) - -@mcp.tool -def delete_user(user_id: str) -> str: - return f"Deleted user {user_id}" - -@mcp.tool -def get_user(user_id: str) -> str: - return f"User {user_id}" # Not protected -``` diff --git a/docs/v3/servers/pagination.mdx b/docs/v3/servers/pagination.mdx deleted file mode 100644 index 97ad2c7a2..000000000 --- a/docs/v3/servers/pagination.mdx +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: Pagination -sidebarTitle: Pagination -description: Control how servers return large lists of components to clients. -icon: page -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -When a server exposes many tools, resources, or prompts, returning them all in a single response can be impractical. MCP supports pagination for list operations, allowing servers to return results in manageable chunks that clients can fetch incrementally. - -## Server Configuration - -By default, FastMCP servers return all components in a single response for backward compatibility. To enable pagination, set the `list_page_size` parameter when creating your server. This value determines the maximum number of items returned per page across all list operations. - -```python -from fastmcp import FastMCP - -# Enable pagination with 50 items per page -server = FastMCP("ComponentRegistry", list_page_size=50) - -# Register tools (in practice, these might come from a database or config) -@server.tool -def search(query: str) -> str: - return f"Results for: {query}" - -@server.tool -def analyze(data: str) -> dict: - return {"status": "analyzed", "data": data} - -# ... many more tools, resources, prompts -``` - -When `list_page_size` is configured, the `tools/list`, `resources/list`, `resources/templates/list`, and `prompts/list` endpoints all paginate their responses. Each response includes a `nextCursor` field when more results exist, which clients use to fetch subsequent pages. - -### Cursor Format - -Cursors are opaque base64-encoded strings per the MCP specification. Clients should treat them as black boxes, passing them unchanged between requests. The cursor encodes the offset into the result set, but this is an implementation detail that may change. - -## Client Behavior - -The FastMCP Client handles pagination transparently. Convenience methods like `list_tools()`, `list_resources()`, `list_resource_templates()`, and `list_prompts()` automatically fetch all pages and return the complete list. Existing code continues to work without modification. - -```python -from fastmcp import Client - -async with Client(server) as client: - # Returns all 200 tools, fetching pages automatically - tools = await client.list_tools() - print(f"Total tools: {len(tools)}") # 200 -``` - -### Manual Pagination - -For scenarios where you want to process results incrementally (memory-constrained environments, progress reporting, or early termination), use the `_mcp` variants with explicit cursor handling. - -```python -from fastmcp import Client - -async with Client(server) as client: - # Fetch first page - result = await client.list_tools_mcp() - print(f"Page 1: {len(result.tools)} tools") - - # Continue fetching while more pages exist - while result.nextCursor: - result = await client.list_tools_mcp(cursor=result.nextCursor) - print(f"Next page: {len(result.tools)} tools") -``` - -The `_mcp` methods return the raw MCP protocol objects, which include both the items and the `nextCursor` for the next page. When `nextCursor` is `None`, you've reached the end of the result set. - -All four list operations support manual pagination: - -| Operation | Convenience Method | Manual Method | -|-----------|-------------------|---------------| -| Tools | `list_tools()` | `list_tools_mcp(cursor=...)` | -| Resources | `list_resources()` | `list_resources_mcp(cursor=...)` | -| Resource Templates | `list_resource_templates()` | `list_resource_templates_mcp(cursor=...)` | -| Prompts | `list_prompts()` | `list_prompts_mcp(cursor=...)` | - -## When to Use Pagination - -Pagination becomes valuable when your server exposes a large number of components. Consider enabling it when: - -- Your server dynamically generates many components (e.g., from a database or file system) -- Memory usage is a concern for clients -- You want to reduce initial response latency - -For servers with a fixed, modest number of components (fewer than 100), pagination adds complexity without meaningful benefit. The default behavior of returning everything in one response is simpler and efficient for typical use cases. diff --git a/docs/v3/servers/progress.mdx b/docs/v3/servers/progress.mdx deleted file mode 100644 index 9600a05ce..000000000 --- a/docs/v3/servers/progress.mdx +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Progress Reporting -sidebarTitle: Progress -description: Update clients on the progress of long-running operations through the MCP context. -icon: chart-line ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -Progress reporting allows MCP tools to notify clients about the progress of long-running operations. Clients can display progress indicators and provide better user experience during time-consuming tasks. - -## Basic Usage - -Use `ctx.report_progress()` to send progress updates to the client. The method accepts a `progress` value representing how much work is complete, and an optional `total` representing the full scope of work. - -```python -from fastmcp import FastMCP, Context -import asyncio - -mcp = FastMCP("ProgressDemo") - -@mcp.tool -async def process_items(items: list[str], ctx: Context) -> dict: - """Process a list of items with progress updates.""" - total = len(items) - results = [] - - for i, item in enumerate(items): - await ctx.report_progress(progress=i, total=total) - await asyncio.sleep(0.1) - results.append(item.upper()) - - await ctx.report_progress(progress=total, total=total) - return {"processed": len(results), "results": results} -``` - -## Progress Patterns - -| Pattern | Description | Example | -|---------|-------------|---------| -| Percentage | Progress as 0-100 percentage | `progress=75, total=100` | -| Absolute | Completed items of a known count | `progress=3, total=10` | -| Indeterminate | Progress without known endpoint | `progress=files_found` (no total) | - -For multi-stage operations, map each stage to a portion of the total progress range. A four-stage operation might allocate 0-25% to validation, 25-60% to export, 60-80% to transform, and 80-100% to import. - -## Client Requirements - -Progress reporting requires clients to support progress handling. Clients must send a `progressToken` in the initial request to receive progress updates. If no progress token is provided, progress calls have no effect (they don't error). - -See [Client Progress](/clients/progress) for details on implementing client-side progress handling. diff --git a/docs/v3/servers/prompts.mdx b/docs/v3/servers/prompts.mdx deleted file mode 100644 index b5cf2f6e9..000000000 --- a/docs/v3/servers/prompts.mdx +++ /dev/null @@ -1,481 +0,0 @@ ---- -title: Prompts -sidebarTitle: Prompts -description: Create reusable, parameterized prompt templates for MCP clients. -icon: message-lines ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -Prompts are reusable message templates that help LLMs generate structured, purposeful responses. FastMCP simplifies defining these templates, primarily using the `@mcp.prompt` decorator. - -## What Are Prompts? - -Prompts provide parameterized message templates for LLMs. When a client requests a prompt: - -1. FastMCP finds the corresponding prompt definition. -2. If it has parameters, they are validated against your function signature. -3. Your function executes with the validated inputs. -4. The generated message(s) are returned to the LLM to guide its response. - -This allows you to define consistent, reusable templates that LLMs can use across different clients and contexts. - -## Prompts - -### The `@prompt` Decorator - -The most common way to define a prompt is by decorating a Python function. The decorator uses the function name as the prompt's identifier. - -```python -from fastmcp import FastMCP -from fastmcp.prompts import Message - -mcp = FastMCP(name="PromptServer") - -# Basic prompt returning a string (converted to user message automatically) -@mcp.prompt -def ask_about_topic(topic: str) -> str: - """Generates a user message asking for an explanation of a topic.""" - return f"Can you please explain the concept of '{topic}'?" - -# Prompt returning multiple messages -@mcp.prompt -def generate_code_request(language: str, task_description: str) -> list[Message]: - """Generates a conversation for code generation.""" - return [ - Message(f"Write a {language} function that performs the following task: {task_description}"), - Message("I'll help you write that function.", role="assistant"), - ] -``` - -**Key Concepts:** - -* **Name:** By default, the prompt name is taken from the function name. -* **Parameters:** The function parameters define the inputs needed to generate the prompt. -* **Inferred Metadata:** By default: - * Prompt Name: Taken from the function name (`ask_about_topic`). - * Prompt Description: Taken from the summary of the function's docstring. If the docstring includes parameter descriptions (Google, NumPy, or Sphinx style), they populate each prompt argument's description in the MCP protocol (see [Argument Descriptions](#argument-descriptions)). -<Tip> -Functions with `*args` or `**kwargs` are not supported as prompts. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists. -</Tip> - -#### Decorator Arguments - -While FastMCP infers the name and description from your function, you can override these and add additional metadata using arguments to the `@mcp.prompt` decorator: - -```python -@mcp.prompt( - name="analyze_data_request", # Custom prompt name - description="Creates a request to analyze data with specific parameters", # Custom description - tags={"analysis", "data"}, # Optional categorization tags - meta={"version": "1.1", "author": "data-team"} # Custom metadata -) -def data_analysis_prompt( - data_uri: str = Field(description="The URI of the resource containing the data."), - analysis_type: str = Field(default="summary", description="Type of analysis.") -) -> str: - """This docstring is ignored when description is provided.""" - return f"Please perform a '{analysis_type}' analysis on the data found at {data_uri}." -``` - -<Card icon="code" title="@prompt Decorator Arguments"> -<ParamField body="name" type="str | None"> - Sets the explicit prompt name exposed via MCP. If not provided, uses the function name -</ParamField> - -<ParamField body="title" type="str | None"> - A human-readable title for the prompt -</ParamField> - -<ParamField body="description" type="str | None"> - Provides the description exposed via MCP. If set, the function's docstring is ignored for the prompt description, though docstring-derived argument descriptions still apply (see [Argument Descriptions](#argument-descriptions)). -</ParamField> - -<ParamField body="tags" type="set[str] | None"> - A set of strings used to categorize the prompt. These can be used by the server and, in some cases, by clients to filter or group available prompts. -</ParamField> - -<ParamField body="enabled" type="bool" default="True"> - <Warning>Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead.</Warning> - A boolean to enable or disable the prompt. See [Component Visibility](#component-visibility) for the recommended approach. -</ParamField> - -<ParamField body="icons" type="list[Icon] | None"> - <VersionBadge version="2.13.0" /> - - Optional list of icon representations for this prompt. See [Icons](/servers/icons) for detailed examples -</ParamField> - -<ParamField body="meta" type="dict[str, Any] | None"> - <VersionBadge version="2.11.0" /> - - Optional meta information about the prompt. This data is passed through to the MCP client as the `meta` field of the client-side prompt object and can be used for custom metadata, versioning, or other application-specific purposes. -</ParamField> - -<ParamField body="version" type="str | int | None"> - <VersionBadge version="3.0.0" /> - - Optional version identifier for this prompt. See [Versioning](/servers/versioning) for details. -</ParamField> -</Card> - -#### Using with Methods - -For decorating instance or class methods, use the standalone `@prompt` decorator and register the bound method. See [Tools: Using with Methods](/servers/tools#using-with-methods) for the pattern. - -### Argument Types - -<VersionBadge version="2.9.0" /> - -The MCP specification requires that all prompt arguments be passed as strings, but FastMCP allows you to use typed annotations for better developer experience. When you use complex types like `list[int]` or `dict[str, str]`, FastMCP: - -1. **Automatically converts** string arguments from MCP clients to the expected types -2. **Generates helpful descriptions** showing the exact JSON string format needed -3. **Preserves direct usage** - you can still call prompts with properly typed arguments - -Since the MCP specification only allows string arguments, clients need to know what string format to use for complex types. FastMCP solves this by automatically enhancing the argument descriptions with JSON schema information, making it clear to both humans and LLMs how to format their arguments. - -<CodeGroup> - -```python Python Code -@mcp.prompt -def analyze_data( - numbers: list[int], - metadata: dict[str, str], - threshold: float -) -> str: - """Analyze numerical data.""" - avg = sum(numbers) / len(numbers) - return f"Average: {avg}, above threshold: {avg > threshold}" -``` - -```json Resulting MCP Prompt -{ - "name": "analyze_data", - "description": "Analyze numerical data.", - "arguments": [ - { - "name": "numbers", - "description": "Provide as a JSON string matching the following schema: {\"items\":{\"type\":\"integer\"},\"type\":\"array\"}", - "required": true - }, - { - "name": "metadata", - "description": "Provide as a JSON string matching the following schema: {\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", - "required": true - }, - { - "name": "threshold", - "description": "Provide as a JSON string matching the following schema: {\"type\":\"number\"}", - "required": true - } - ] -} -``` - -</CodeGroup> - -**MCP clients will call this prompt with string arguments:** -```json -{ - "numbers": "[1, 2, 3, 4, 5]", - "metadata": "{\"source\": \"api\", \"version\": \"1.0\"}", - "threshold": "2.5" -} -``` - -**But you can still call it directly with proper types:** -```python -# This also works for direct calls -result = await prompt.render({ - "numbers": [1, 2, 3, 4, 5], - "metadata": {"source": "api", "version": "1.0"}, - "threshold": 2.5 -}) -``` - -<Warning> -Keep your type annotations simple when using this feature. Complex nested types or custom classes may not convert reliably from JSON strings. The automatically generated schema descriptions are the only guidance users receive about the expected format. - -Good choices: `list[int]`, `dict[str, str]`, `float`, `bool` -Avoid: Complex Pydantic models, deeply nested structures, custom classes -</Warning> - -### Argument Descriptions - -<VersionBadge version="3.2.4" /> - -FastMCP parses your function's docstring to extract the prompt description and per-argument descriptions. Google, NumPy, and Sphinx styles are all supported: - -```python -@mcp.prompt -def analyze_data(dataset: str, method: str = "summary") -> str: - """Generate an analysis prompt for a dataset. - - Args: - dataset: URI or identifier of the dataset to analyze. - method: Type of analysis to perform (summary, detailed, etc). - """ - return f"Please perform a '{method}' analysis on {dataset}." -``` - -The free-form text above the `Args` section — whether a single line or multiple paragraphs — becomes the prompt description, and each argument's docstring entry becomes the description on the corresponding `PromptArgument` in the MCP protocol. Sections like `Returns`, `Raises`, and `Example` are excluded from the description but otherwise ignored. - -If an argument already has an explicit description — via `Annotated[x, "..."]` or `Field(description=...)` — that description takes precedence over the docstring. This makes it safe to adopt docstring-based descriptions incrementally: existing annotations keep working, and docstrings fill in the gaps. - -### Return Values - -Prompt functions must return one of these types: - -- **`str`**: Sent as a single user message. -- **`list[Message | str]`**: A sequence of messages (a conversation). Strings are auto-converted to user Messages. -- **`PromptResult`**: Full control over messages, description, and metadata. See [PromptResult](#promptresult) below. - -```python -from fastmcp.prompts import Message - -@mcp.prompt -def roleplay_scenario(character: str, situation: str) -> list[Message]: - """Sets up a roleplaying scenario with initial messages.""" - return [ - Message(f"Let's roleplay. You are {character}. The situation is: {situation}"), - Message("Okay, I understand. I am ready. What happens next?", role="assistant") - ] -``` - -#### Message - -<VersionBadge version="3.0.0" /> - -`Message` provides a user-friendly wrapper for prompt messages with automatic serialization. - -```python -from fastmcp.prompts import Message - -# String content (user role by default) -Message("Hello, world!") - -# Explicit role -Message("I can help with that.", role="assistant") - -# Auto-serialized to JSON text -Message({"key": "value"}) -Message(["item1", "item2"]) -``` - -`Message` accepts two fields: - -**`content`** - The message content. Strings pass through directly. Other types (dict, list, BaseModel) are automatically JSON-serialized to text. - -**`role`** - The message role, either `"user"` (default) or `"assistant"`. - -<Card title="Message"> -<ParamField body="content" type="Any" required> - The content data. Strings pass through directly. Other types (dict, list, BaseModel) are automatically JSON-serialized. -</ParamField> -<ParamField body="role" type="Literal['user', 'assistant']" default="user"> - The message role. -</ParamField> -</Card> - -#### PromptResult - -<VersionBadge version="3.0.0" /> - -`PromptResult` gives you explicit control over prompt responses: multiple messages, roles, and metadata at both the message and result level. - -```python test="skip" -from fastmcp import FastMCP -from fastmcp.prompts import PromptResult, Message - -mcp = FastMCP(name="PromptServer") - -@mcp.prompt -def code_review(code: str) -> PromptResult: - """Returns a code review prompt with metadata.""" - return PromptResult( - messages=[ - Message(f"Please review this code:\n\n```\n{code}\n```"), - Message("I'll analyze this code for issues.", role="assistant"), - ], - description="Code review prompt", - meta={"review_type": "security", "priority": "high"} - ) -``` - -For simple cases, you can pass a string directly to `PromptResult`: - -```python -return PromptResult("Please help me with this task") # auto-converts to single Message -``` - -<Card title="PromptResult"> -<ParamField body="messages" type="str | list[Message]" required> - Messages to return. Strings are wrapped as a single user Message. -</ParamField> -<ParamField body="description" type="str | None"> - Optional description of the prompt result. If not provided, defaults to the prompt's docstring. -</ParamField> -<ParamField body="meta" type="dict[str, Any] | None"> - Result-level metadata, included in the MCP response's `_meta` field. Use this for runtime metadata like categorization, priority, or other client-specific data. -</ParamField> -</Card> - -<Note> -The `meta` field in `PromptResult` is for runtime metadata specific to this render response. This is separate from the `meta` parameter in `@mcp.prompt(meta={...})`, which provides static metadata about the prompt definition itself (returned when listing prompts). -</Note> - -You can still return plain `str` or `list[Message | str]` from your prompt functions—`PromptResult` is opt-in for when you need to include metadata. - -### Required vs. Optional Parameters - -Parameters in your function signature are considered **required** unless they have a default value. - -```python -@mcp.prompt -def data_analysis_prompt( - data_uri: str, # Required - no default value - analysis_type: str = "summary", # Optional - has default value - include_charts: bool = False # Optional - has default value -) -> str: - """Creates a request to analyze data with specific parameters.""" - prompt = f"Please perform a '{analysis_type}' analysis on the data found at {data_uri}." - if include_charts: - prompt += " Include relevant charts and visualizations." - return prompt -``` - -In this example, the client *must* provide `data_uri`. If `analysis_type` or `include_charts` are omitted, their default values will be used. - -### Component Visibility - -<VersionBadge version="3.0.0" /> - -You can control which prompts are enabled for clients using server-level enabled control. Disabled prompts don't appear in `list_prompts` and can't be called. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -@mcp.prompt(tags={"public"}) -def public_prompt(topic: str) -> str: - return f"Discuss: {topic}" - -@mcp.prompt(tags={"internal"}) -def internal_prompt() -> str: - return "Internal system prompt" - -# Disable specific prompts by key -mcp.disable(keys={"prompt:internal_prompt"}) - -# Disable prompts by tag -mcp.disable(tags={"internal"}) - -# Or use allowlist mode - only enable prompts with specific tags -mcp.enable(tags={"public"}, only=True) -``` - -See [Visibility](/servers/visibility) for the complete visibility control API including key formats, tag-based filtering, and provider-level control. - -### Async Prompts - -FastMCP supports both standard (`def`) and asynchronous (`async def`) functions as prompts. Synchronous functions automatically run in a threadpool to avoid blocking the event loop. - -```python -# Synchronous prompt (runs in threadpool) -@mcp.prompt -def simple_question(question: str) -> str: - """Generates a simple question to ask the LLM.""" - return f"Question: {question}" - -# Asynchronous prompt -@mcp.prompt -async def data_based_prompt(data_id: str) -> str: - """Generates a prompt based on data that needs to be fetched.""" - # In a real scenario, you might fetch data from a database or API - async with aiohttp.ClientSession() as session: - async with session.get(f"https://api.example.com/data/{data_id}") as response: - data = await response.json() - return f"Analyze this data: {data['content']}" -``` - -Use `async def` when your prompt function performs I/O operations like network requests or database queries, since async is more efficient than threadpool dispatch. - -### Accessing MCP Context - -<VersionBadge version="2.2.5" /> - -Prompts can access additional MCP information and features through the `Context` object. To access it, add a parameter to your prompt function with a type annotation of `Context`: - -```python {6} -from fastmcp import FastMCP, Context - -mcp = FastMCP(name="PromptServer") - -@mcp.prompt -async def generate_report_request(report_type: str, ctx: Context) -> str: - """Generates a request for a report.""" - return f"Please create a {report_type} report. Request ID: {ctx.request_id}" -``` - -For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). - -### Notifications - -<VersionBadge version="2.9.1" /> - -FastMCP automatically sends `notifications/prompts/list_changed` notifications to connected clients when prompts are added, enabled, or disabled. This allows clients to stay up-to-date with the current prompt set without manually polling for changes. - -```python -@mcp.prompt -def example_prompt() -> str: - return "Hello!" - -# These operations trigger notifications: -mcp.add_prompt(example_prompt) # Sends prompts/list_changed notification -mcp.disable(keys={"prompt:example_prompt"}) # Sends prompts/list_changed notification -mcp.enable(keys={"prompt:example_prompt"}) # Sends prompts/list_changed notification -``` - -Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications. - -Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their prompt lists or update their interfaces. - -## Server Behavior - -### Duplicate Prompts - -<VersionBadge version="2.1.0" /> - -You can configure how the FastMCP server handles attempts to register multiple prompts with the same name. Use the `on_duplicate_prompts` setting during `FastMCP` initialization. - -```python -from fastmcp import FastMCP - -mcp = FastMCP( - name="PromptServer", - on_duplicate_prompts="error" # Raise an error if a prompt name is duplicated -) - -@mcp.prompt -def greeting(): return "Hello, how can I help you today?" - -# This registration attempt will raise a ValueError because -# "greeting" is already registered and the behavior is "error". -# @mcp.prompt -# def greeting(): return "Hi there! What can I do for you?" -``` - -The duplicate behavior options are: - -- `"warn"` (default): Logs a warning, and the new prompt replaces the old one. -- `"error"`: Raises a `ValueError`, preventing the duplicate registration. -- `"replace"`: Silently replaces the existing prompt with the new one. -- `"ignore"`: Keeps the original prompt and ignores the new registration attempt. - -## Versioning - -<VersionBadge version="3.0.0" /> - -Prompts support versioning, allowing you to maintain multiple implementations under the same name while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns. diff --git a/docs/v3/servers/providers/custom.mdx b/docs/v3/servers/providers/custom.mdx deleted file mode 100644 index f5673c683..000000000 --- a/docs/v3/servers/providers/custom.mdx +++ /dev/null @@ -1,245 +0,0 @@ ---- -title: Custom Providers -sidebarTitle: Custom -description: Build providers that source components from any data source -icon: code -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Custom providers let you source components from anywhere - databases, APIs, configuration systems, or dynamic runtime logic. If you can write Python code to fetch or generate a component, you can wrap it in a provider. - -## When to Build Custom - -The built-in providers handle common cases: decorators (`LocalProvider`), composition (`FastMCPProvider`), and proxying (`ProxyProvider`). Build a custom provider when your components come from somewhere else: - -- **Database-backed tools**: Admin users define tools in a database, and your server exposes them dynamically -- **API-backed resources**: Resources that fetch content from external services on demand -- **Configuration-driven components**: Components loaded from YAML/JSON config files at startup -- **Multi-tenant systems**: Different users see different tools based on their permissions -- **Plugin systems**: Third-party code registers components at runtime - -## Providers vs Middleware - -Both providers and [middleware](/servers/middleware) can influence what components a client sees, but they work at different levels. - -**Providers** are objects that source components. They make it easy to reason about where tools, resources, and prompts come from - a database, another server, an API. - -**Middleware** intercepts individual requests. It's well-suited for request-specific decisions like logging, rate limiting, or authentication. - -You *could* use middleware to dynamically add tools based on request context. But it's often cleaner to have a provider source all possible tools, then use middleware or [visibility controls](/servers/visibility) to filter what each request can see. This separation makes it easier to reason about how components are sourced and how they interact with other server machinery. - -## The Provider Interface - -A provider implements protected `_list_*` methods that return available components. The public `list_*` methods handle transforms automatically - you override the underscore-prefixed versions: - -```python -from collections.abc import Sequence -from fastmcp.server.providers import Provider -from fastmcp.tools import Tool -from fastmcp.resources import Resource -from fastmcp.prompts import Prompt - -class MyProvider(Provider): - async def _list_tools(self) -> Sequence[Tool]: - """Return all tools this provider offers.""" - return [] - - async def _list_resources(self) -> Sequence[Resource]: - """Return all resources this provider offers.""" - return [] - - async def _list_prompts(self) -> Sequence[Prompt]: - """Return all prompts this provider offers.""" - return [] -``` - -You only need to implement the methods for component types you provide. The base class returns empty sequences by default. - -The `_get_*` methods (`_get_tool`, `_get_resource`, `_get_prompt`) have default implementations that search through the list results. Override them only if you can fetch individual components more efficiently than iterating the full list. - -## What Providers Return - -Providers return component objects that are ready to use. When a client calls a tool, FastMCP invokes the tool's function - your provider isn't involved in execution. This means the `Tool`, `Resource`, or `Prompt` you return must actually work. - -The easiest way to create components is from functions: - -```python -from fastmcp.tools import Tool - -def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - -tool = Tool.from_function(add) -``` - -The function's type hints become the input schema, and the docstring becomes the description. You can override these: - -```python -tool = Tool.from_function( - add, - name="calculator_add", - description="Add two integers together" -) -``` - -Similar `from_function` methods exist for `Resource` and `Prompt`. - -## Registering Providers - -Add providers when creating the server: - -```python -mcp = FastMCP( - "MyServer", - providers=[ - DatabaseProvider(db_url), - ConfigProvider(config_path), - ] -) -``` - -Or add them after creation: - -```python -mcp = FastMCP("MyServer") -mcp.add_provider(DatabaseProvider(db_url)) -``` - -## A Simple Provider - -Here's a minimal provider that serves tools from a dictionary: - -```python -from collections.abc import Callable, Sequence -from fastmcp import FastMCP -from fastmcp.server.providers import Provider -from fastmcp.tools import Tool - -class DictProvider(Provider): - def __init__(self, tools: dict[str, Callable]): - super().__init__() - self._tools = [ - Tool.from_function(fn, name=name) - for name, fn in tools.items() - ] - - async def _list_tools(self) -> Sequence[Tool]: - return self._tools -``` - -Use it like this: - -```python -def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - -def multiply(a: int, b: int) -> int: - """Multiply two numbers.""" - return a * b - -mcp = FastMCP("Calculator", providers=[ - DictProvider({"add": add, "multiply": multiply}) -]) -``` - -## Lifecycle Management - -Providers often need to set up connections when the server starts and clean them up when it stops. Override the `lifespan` method: - -```python -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator, Sequence - -class DatabaseProvider(Provider): - def __init__(self, db_url: str): - super().__init__() - self.db_url = db_url - self.db = None - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - self.db = await connect_database(self.db_url) - try: - yield - finally: - await self.db.close() - - async def _list_tools(self) -> Sequence[Tool]: - rows = await self.db.fetch("SELECT * FROM tools") - return [self._make_tool(row) for row in rows] -``` - -FastMCP calls your provider's `lifespan` during server startup and shutdown. The connection is available to your methods while the server runs. - -## Full Example: API-Backed Resources - -Here's a complete provider that fetches resources from an external REST API: - -```python -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator, Sequence -from fastmcp.server.providers import Provider -from fastmcp.resources import Resource -import httpx - -class ApiResourceProvider(Provider): - """Provides resources backed by an external API.""" - - def __init__(self, base_url: str, api_key: str): - super().__init__() - self.base_url = base_url - self.api_key = api_key - self.client = None - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - self.client = httpx.AsyncClient( - base_url=self.base_url, - headers={"Authorization": f"Bearer {self.api_key}"} - ) - try: - yield - finally: - await self.client.aclose() - - async def _list_resources(self) -> Sequence[Resource]: - response = await self.client.get("/resources") - response.raise_for_status() - return [ - self._make_resource(item) - for item in response.json()["items"] - ] - - def _make_resource(self, data: dict) -> Resource: - resource_id = data["id"] - - async def read_content() -> str: - response = await self.client.get( - f"/resources/{resource_id}/content" - ) - return response.text - - return Resource.from_function( - read_content, - uri=f"api://resources/{resource_id}", - name=data["name"], - description=data.get("description", ""), - mime_type=data.get("mime_type", "text/plain") - ) -``` - -Register it like any other provider: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("API Resources", providers=[ - ApiResourceProvider("https://api.example.com", "my-api-key") -]) -``` diff --git a/docs/v3/servers/providers/filesystem.mdx b/docs/v3/servers/providers/filesystem.mdx deleted file mode 100644 index 353a671d5..000000000 --- a/docs/v3/servers/providers/filesystem.mdx +++ /dev/null @@ -1,256 +0,0 @@ ---- -title: Filesystem Provider -sidebarTitle: Filesystem -description: Automatic component discovery from Python files -icon: folder-tree -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -`FileSystemProvider` scans a directory for Python files and automatically registers functions decorated with `@tool`, `@resource`, or `@prompt`. This enables a file-based organization pattern similar to Next.js routing, where your project structure becomes your component registry. - -## Why Filesystem Discovery - -Traditional FastMCP servers require coordination between files. Either your tool files import the server to call `@server.tool()`, or your server file imports all the tool modules. Both approaches create coupling that some developers prefer to avoid. - -`FileSystemProvider` eliminates this coordination. Each file is self-contained—it uses standalone decorators (`@tool`, `@resource`, `@prompt`) that don't require access to a server instance. The provider discovers these files at startup, so you can add new tools without modifying your server file. - -This is a convention some teams prefer, not necessarily better for all projects. The tradeoffs: - -- **No coordination**: Files don't import the server; server doesn't import files -- **Predictable naming**: Function names become component names (unless overridden) -- **Development mode**: Optionally re-scan files on every request for rapid iteration - -## Quick Start - -Create a provider pointing to your components directory, then pass it to your server. Use `Path(__file__).parent` to make the path relative to your server file. - -```python -from pathlib import Path - -from fastmcp import FastMCP -from fastmcp.server.providers import FileSystemProvider - -mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "components")]) -``` - -In your `components/` directory, create Python files with decorated functions. - -```python -# components/tools/greet.py -from fastmcp.tools import tool - -@tool -def greet(name: str) -> str: - """Greet someone by name.""" - return f"Hello, {name}!" -``` - -When the server starts, `FileSystemProvider` scans the directory, imports all Python files, and registers any decorated functions it finds. - -## Decorators - -FastMCP provides standalone decorators that mark functions for discovery: `@tool` from `fastmcp.tools`, `@resource` from `fastmcp.resources`, and `@prompt` from `fastmcp.prompts`. These support the full syntax of server-bound decorators—all the same parameters work identically. - -### @tool - -Mark a function as a tool. The function name becomes the tool name by default. - -```python -from fastmcp.tools import tool - -@tool -def calculate_sum(a: float, b: float) -> float: - """Add two numbers together.""" - return a + b -``` - -Customize the tool with optional parameters. - -```python -from fastmcp.tools import tool - -@tool( - name="add-numbers", - description="Add two numbers together.", - tags={"math", "arithmetic"}, -) -def add(a: float, b: float) -> float: - return a + b -``` - -The decorator supports all standard tool options: `name`, `title`, `description`, `icons`, `tags`, `output_schema`, `annotations`, and `meta`. - -### @resource - -Mark a function as a resource. Unlike `@tool`, the `@resource` decorator requires a URI argument. - -```python -from fastmcp.resources import resource - -@resource("config://app") -def get_app_config() -> str: - """Get application configuration.""" - return '{"version": "1.0"}' -``` - -URIs with template parameters create resource templates. The provider automatically detects whether to register a static resource or a template based on whether the URI contains `{parameters}` or the function has arguments. - -```python -from fastmcp.resources import resource - -@resource("users://{user_id}/profile") -def get_user_profile(user_id: str) -> str: - """Get a user's profile by ID.""" - return f'{{"id": "{user_id}", "name": "User"}}' -``` - -The decorator supports: `uri` (required), `name`, `title`, `description`, `icons`, `mime_type`, `tags`, `annotations`, and `meta`. - -### @prompt - -Mark a function as a prompt template. - -```python test="skip" -from fastmcp.prompts import prompt - -@prompt -def code_review(code: str, language: str = "python") -> str: - """Generate a code review prompt.""" - return f"Please review this {language} code:\n\n```{language}\n{code}\n```" -``` - -```python -from fastmcp.prompts import prompt - -@prompt(name="explain-concept", tags={"education"}) -def explain(topic: str) -> str: - """Generate an explanation prompt.""" - return f"Explain {topic} using clear examples and analogies." -``` - -The decorator supports: `name`, `title`, `description`, `icons`, `tags`, and `meta`. - -## Directory Structure - -The directory structure is purely organizational. The provider recursively scans all `.py` files regardless of which subdirectory they're in. Subdirectories like `tools/`, `resources/`, and `prompts/` are optional conventions that help you organize code. - -``` -components/ -├── tools/ -│ ├── greeting.py # @tool functions -│ └── calculator.py # @tool functions -├── resources/ -│ └── config.py # @resource functions -└── prompts/ - └── assistant.py # @prompt functions -``` - -You can also put all components in a single file or organize by feature rather than type. - -``` -components/ -├── user_management.py # @tool, @resource, @prompt for users -├── billing.py # @tool, @resource for billing -└── analytics.py # @tool for analytics -``` - -## Discovery Rules - -The provider follows these rules when scanning: - -| Rule | Behavior | -|------|----------| -| File extensions | Only `.py` files are scanned | -| `__init__.py` | Skipped (used for package structure, not components) | -| `__pycache__` | Skipped | -| Private functions | Functions starting with `_` are ignored, even if decorated | -| No decorators | Files without `@tool`, `@resource`, or `@prompt` are silently skipped | -| Multiple components | A single file can contain any number of decorated functions | - -### Package Imports - -If your directory contains an `__init__.py` file, the provider imports files as proper Python package members. This means relative imports work correctly within your components directory. - -```python -# components/__init__.py exists - -# components/tools/greeting.py -from ..helpers import format_name # Relative imports work - -@tool -def greet(name: str) -> str: - return f"Hello, {format_name(name)}!" -``` - -Without `__init__.py`, files are imported directly using `importlib.util.spec_from_file_location`. - -## Reload Mode - -During development, you may want changes to component files to take effect without restarting the server. Enable reload mode to re-scan the directory on every request. - -```python -from pathlib import Path - -from fastmcp.server.providers import FileSystemProvider - -provider = FileSystemProvider(Path(__file__).parent / "components", reload=True) -``` - -With `reload=True`, the provider: - -1. Re-discovers all Python files on each request -2. Re-imports modules that have changed -3. Updates the component registry with any new, modified, or removed components - -<Warning> -Reload mode adds overhead to every request. Use it only during development, not in production. -</Warning> - -## Error Handling - -When a file fails to import (syntax error, missing dependency, etc.), the provider logs a warning and continues scanning other files. Failed imports don't prevent the server from starting. - -``` -WARNING - Failed to import /path/to/broken.py: No module named 'missing_dep' -``` - -The provider tracks which files have failed and only re-logs warnings when the file's modification time changes. This prevents log spam when a broken file is repeatedly scanned in reload mode. - -## Example Project - -A complete example is available in the repository at `examples/filesystem-provider/`. The structure demonstrates the recommended organization. - -``` -examples/filesystem-provider/ -├── server.py # Server entry point -└── components/ - ├── tools/ - │ ├── greeting.py # greet, farewell tools - │ └── calculator.py # add, multiply tools - ├── resources/ - │ └── config.py # Static and templated resources - └── prompts/ - └── assistant.py # code_review, explain prompts -``` - -The server entry point is minimal. - -```python -from pathlib import Path - -from fastmcp import FastMCP -from fastmcp.server.providers import FileSystemProvider - -provider = FileSystemProvider( - root=Path(__file__).parent / "components", - reload=True, -) - -mcp = FastMCP("FilesystemDemo", providers=[provider]) -``` - -Run with `fastmcp run examples/filesystem-provider/server.py` or inspect with `fastmcp inspect examples/filesystem-provider/server.py`. diff --git a/docs/v3/servers/providers/local.mdx b/docs/v3/servers/providers/local.mdx deleted file mode 100644 index 86726655a..000000000 --- a/docs/v3/servers/providers/local.mdx +++ /dev/null @@ -1,161 +0,0 @@ ---- -title: Local Provider -sidebarTitle: Local -description: The default provider for decorator-registered components -icon: house -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -`LocalProvider` stores components that you define directly on your server. When you use `@mcp.tool`, `@mcp.resource`, or `@mcp.prompt`, you're adding components to your server's `LocalProvider`. - -## How It Works - -Every FastMCP server has a `LocalProvider` as its first provider. Components registered via decorators or direct methods are stored here: - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -# These are stored in the server's `LocalProvider` -@mcp.tool -def greet(name: str) -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - -@mcp.resource("data://config") -def get_config() -> str: - """Return configuration data.""" - return '{"version": "1.0"}' - -@mcp.prompt -def analyze(topic: str) -> str: - """Create an analysis prompt.""" - return f"Please analyze: {topic}" -``` - -The `LocalProvider` is always queried first when clients request components, ensuring that your directly-defined components take precedence over those from mounted or proxied servers. - -## Component Registration - -### Using Decorators - -The most common way to register components: - -```python -@mcp.tool -def my_tool(x: int) -> str: - return str(x) - -@mcp.resource("data://info") -def my_resource() -> str: - return "info" - -@mcp.prompt -def my_prompt(topic: str) -> str: - return f"Discuss: {topic}" -``` - -### Using Direct Methods - -You can also add pre-built component objects: - -```python -from fastmcp.tools import Tool - -# Create a tool object -my_tool = Tool.from_function(some_function, name="custom_tool") - -# Add it to the server -mcp.add_tool(my_tool) -mcp.add_resource(my_resource) -mcp.add_prompt(my_prompt) -``` - -### Removing Components - -Remove components by name or URI: - -```python -mcp.local_provider.remove_tool("my_tool") -mcp.local_provider.remove_resource("data://info") -mcp.local_provider.remove_prompt("my_prompt") -``` - -## Duplicate Handling - -When you try to add a component that already exists, the behavior depends on the `on_duplicate` setting: - -| Mode | Behavior | -|------|----------| -| `"error"` (default) | Raise `ValueError` | -| `"warn"` | Log warning and replace | -| `"replace"` | Silently replace | -| `"ignore"` | Keep existing component | - -Configure this when creating the server: - -```python -mcp = FastMCP("MyServer", on_duplicate="warn") -``` - -## Component Visibility - -<VersionBadge version="3.0.0" /> - -Components can be dynamically enabled or disabled at runtime. Disabled components don't appear in listings and can't be called. - -```python -@mcp.tool(tags={"admin"}) -def delete_all() -> str: - """Delete everything.""" - return "Deleted" - -@mcp.tool -def get_status() -> str: - """Get system status.""" - return "OK" - -# Disable admin tools -mcp.disable(tags={"admin"}) - -# Or only enable specific tools -mcp.enable(keys={"tool:get_status"}, only=True) -``` - -See [Visibility](/servers/visibility) for the full documentation on keys, tags, allowlist mode, and provider-level control. - -## Standalone LocalProvider - -You can create a LocalProvider independently and attach it to multiple servers: - -```python -from fastmcp import FastMCP -from fastmcp.server.providers import LocalProvider - -# Create a reusable provider -shared_tools = LocalProvider() - -@shared_tools.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -@shared_tools.resource("data://version") -def get_version() -> str: - return "1.0.0" - -# Attach to multiple servers -server1 = FastMCP("Server1", providers=[shared_tools]) -server2 = FastMCP("Server2", providers=[shared_tools]) -``` - -This is useful for: -- Sharing components across servers -- Testing components in isolation -- Building reusable component libraries - -Standalone providers also support visibility control with `enable()` and `disable()`. See [Visibility](/servers/visibility) for details. diff --git a/docs/v3/servers/providers/overview.mdx b/docs/v3/servers/providers/overview.mdx deleted file mode 100644 index d3e3e4e5f..000000000 --- a/docs/v3/servers/providers/overview.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Providers -sidebarTitle: Overview -description: How FastMCP sources tools, resources, and prompts -icon: layer-group -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Every FastMCP server has one or more component providers. A provider is a source of tools, resources, and prompts - it's what makes components available to clients. - -## What Is a Provider? - -When a client connects to your server and asks "what tools do you have?", FastMCP asks each provider that question and combines the results. When a client calls a specific tool, FastMCP finds which provider has it and delegates the call. - -You're already using providers. When you write `@mcp.tool`, you're adding a tool to your server's `LocalProvider` - the default provider that stores components you define directly in code. You just don't have to think about it for simple servers. - -Providers become important when your components come from multiple sources: another FastMCP server to include, a remote MCP server to proxy, or a database where tools are defined dynamically. Each source gets its own provider, and FastMCP queries them all seamlessly. - -## Why Providers? - -The provider abstraction solves a common problem: as servers grow, you need to organize components across multiple sources without tangling everything together. - -**Composition**: Break a large server into focused modules. A "weather" server and a "calendar" server can each be developed independently, then mounted into a main server. Each mounted server becomes a `FastMCPProvider`. - -**Proxying**: Expose a remote MCP server through your local server. Maybe you're bridging transports (remote HTTP to local stdio) or aggregating multiple backends. Remote connections become `ProxyProvider` instances. - -**Dynamic sources**: Load tools from a database, generate them from an OpenAPI spec, or create them based on user permissions. Custom providers let components come from anywhere. - -## Built-in Providers - -FastMCP includes providers for common patterns: - -| Provider | What it does | How you use it | -|----------|--------------|----------------| -| `LocalProvider` | Stores components you define in code | `@mcp.tool`, `mcp.add_tool()` | -| `FastMCPProvider` | Wraps another FastMCP server | `mcp.mount(server)` | -| `ProxyProvider` | Connects to remote MCP servers | `create_proxy(client)` | - -Most users only interact with `LocalProvider` (through decorators) and occasionally mount or proxy other servers. The provider abstraction stays invisible until you need it. - -## Transforms - -[Transforms](/servers/transforms/transforms) modify components as they flow from providers to clients. Each transform sits in a chain, intercepting queries and modifying results before passing them along. - -| Transform | Purpose | -|-----------|---------| -| `Namespace` | Prefixes names to avoid conflicts | -| `ToolTransform` | Modifies tool schemas (rename, description, arguments) | - -The most common use is namespacing mounted servers to prevent name collisions. When you call `mount(server, namespace="api")`, FastMCP creates a `Namespace` transform automatically. - -Transforms can be added to individual providers (affecting just that source) or to the server itself (affecting all components). See [Transforms](/servers/transforms/transforms) for the full picture. - -## Provider Order - -When a client requests a tool, FastMCP queries providers in registration order. The first provider that has the tool handles the request. - -`LocalProvider` is always first, so your decorator-defined tools take precedence. Additional providers are queried in the order you added them. This means if two providers have a tool with the same name, the first one wins. - -## When to Care About Providers - -**You can ignore providers entirely** if you're building a simple server with decorators. Just use `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` - FastMCP handles the rest. - -**Learn about providers when** you want to: -- [Mount another server](/servers/composition) into yours -- [Proxy a remote server](/servers/providers/proxy) through yours -- [Control visibility state](/servers/visibility) of components -- [Build dynamic sources](/servers/providers/custom) like database-backed tools - -## Next Steps - -- [Local](/servers/providers/local) - How decorators work -- [Mounting](/servers/composition) - Compose servers together -- [Proxying](/servers/providers/proxy) - Connect to remote servers -- [Transforms](/servers/transforms/transforms) - Namespace, rename, and modify components -- [Visibility](/servers/visibility) - Control which components clients can access -- [Custom](/servers/providers/custom) - Build your own providers diff --git a/docs/v3/servers/providers/proxy.mdx b/docs/v3/servers/providers/proxy.mdx deleted file mode 100644 index 9d64b6148..000000000 --- a/docs/v3/servers/providers/proxy.mdx +++ /dev/null @@ -1,353 +0,0 @@ ---- -title: MCP Proxy Provider -sidebarTitle: MCP Proxy -description: Source components from other MCP servers -icon: arrows-retweet ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="2.0.0" /> - -The Proxy Provider sources components from another MCP server through a client connection. This lets you expose any MCP server's tools, resources, and prompts through your own server, whether the source is local or accessed over the network. - -## Why Use Proxy Provider - -The Proxy Provider enables: - -- **Bridge transports**: Make an HTTP server available via stdio, or vice versa -- **Aggregate servers**: Combine multiple source servers into one unified server -- **Add security**: Act as a controlled gateway with authentication and authorization -- **Simplify access**: Provide a stable endpoint even if backend servers change - -```mermaid -sequenceDiagram - participant Client as Your Client - participant Proxy as FastMCP Proxy - participant Backend as Source Server - - Client->>Proxy: MCP Request (stdio) - Proxy->>Backend: MCP Request (HTTP/stdio/SSE) - Backend-->>Proxy: MCP Response - Proxy-->>Client: MCP Response -``` - -## Quick Start - -<VersionBadge version="2.10.3" /> - -Create a proxy using `create_proxy()`: - -```python -from fastmcp.server import create_proxy - -# create_proxy() accepts URLs, file paths, and transports directly -proxy = create_proxy("http://example.com/mcp", name="MyProxy") - -if __name__ == "__main__": - proxy.run() -``` - -This gives you: - -- Safe concurrent request handling -- Automatic forwarding of MCP features (sampling, elicitation, etc.) -- Session isolation to prevent context mixing - -<Tip> -To mount a proxy inside another FastMCP server, see [Mounting External Servers](/servers/composition#mounting-external-servers). -</Tip> - -## Connection Semantics - -FastMCP proxies are lazy bridges. Creating the proxy object and starting the local server do not contact the upstream server. The upstream connection begins when an MCP client sends an `initialize` request to the proxy. - -During initialization, the proxy initializes the upstream server before responding locally. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or upstream authentication cannot complete, the proxy initialization fails. This keeps the local proxy's connection status aligned with the upstream server it represents. - -After initialization, the proxy forwards MCP requests such as `ping`, `tools/list`, `resources/list`, `prompts/list`, tool calls, resource reads, sampling, elicitation, logging, and progress through the upstream client. - -## Transport Bridging - -A common use case is bridging transports between servers: - -```python -from fastmcp.server import create_proxy - -# Bridge HTTP server to local stdio -http_proxy = create_proxy("http://example.com/mcp/sse", name="HTTP-to-stdio") - -# Run locally via stdio for Claude Desktop -if __name__ == "__main__": - http_proxy.run() # Defaults to stdio -``` - -Or expose a local server via HTTP: - -```python -from fastmcp.server import create_proxy - -# Bridge local server to HTTP -local_proxy = create_proxy("local_server.py", name="stdio-to-HTTP") - -if __name__ == "__main__": - local_proxy.run(transport="http", host="0.0.0.0", port=8080) -``` - -## Session Isolation - -<VersionBadge version="2.10.3" /> - -`create_proxy()` provides session isolation - each request gets its own isolated backend session: - -```python -from fastmcp.server import create_proxy - -# Each request creates a fresh backend session (recommended) -proxy = create_proxy("backend_server.py") - -# Multiple clients can use this proxy simultaneously: -# - Client A calls a tool → gets isolated session -# - Client B calls a tool → gets different session -# - No context mixing -``` - -### Shared Sessions - -If you pass an already-connected client, the proxy reuses that session: - -```python -from fastmcp import Client -from fastmcp.server import create_proxy - -async with Client("backend_server.py") as connected_client: - # This proxy reuses the connected session - proxy = create_proxy(connected_client) - - # ⚠️ Warning: All requests share the same session -``` - -<Warning> -Shared sessions may cause context mixing in concurrent scenarios. Use only in single-threaded situations or with explicit synchronization. -</Warning> - -## MCP Feature Forwarding - -<VersionBadge version="2.10.3" /> - -Proxies automatically forward MCP protocol features: - -| Feature | Description | -|---------|-------------| -| Roots | Filesystem root access requests | -| Sampling | LLM completion requests | -| Elicitation | User input requests | -| Logging | Log messages from backend | -| Progress | Progress notifications | - -```python -from fastmcp.server import create_proxy - -# All features forwarded automatically -proxy = create_proxy("advanced_backend.py") - -# When the backend: -# - Requests LLM sampling → forwarded to your client -# - Logs messages → appear in your client -# - Reports progress → shown in your client -``` - -### Disabling Features - -Selectively disable forwarding: - -```python -from fastmcp.server.providers.proxy import ProxyClient - -backend = ProxyClient( - "backend_server.py", - sampling_handler=None, # Disable LLM sampling - log_handler=None # Disable log forwarding -) -``` - -## Configuration-Based Proxies - -<VersionBadge version="2.4.0" /> - -Create proxies from configuration dictionaries: - -```python -from fastmcp.server import create_proxy - -config = { - "mcpServers": { - "default": { - "url": "https://example.com/mcp", - "transport": "http" - } - } -} - -proxy = create_proxy(config, name="Config-Based Proxy") -``` - -### Multi-Server Proxies - -Combine multiple servers with automatic namespacing: - -```python -from fastmcp.server import create_proxy - -config = { - "mcpServers": { - "weather": { - "url": "https://weather-api.example.com/mcp", - "transport": "http" - }, - "calendar": { - "url": "https://calendar-api.example.com/mcp", - "transport": "http" - } - } -} - -# Creates unified proxy with prefixed components: -# - weather_get_forecast -# - calendar_add_event -composite = create_proxy(config, name="Composite") -``` - -## Component Prefixing - -Proxied components follow standard prefixing rules: - -| Component Type | Pattern | -|----------------|---------| -| Tools | `{prefix}_{tool_name}` | -| Prompts | `{prefix}_{prompt_name}` | -| Resources | `protocol://{prefix}/path` | -| Templates | `protocol://{prefix}/...` | - -## Mirrored Components - -<VersionBadge version="2.10.5" /> - -Components from a proxy server are "mirrored" - they reflect the remote server's state and cannot be modified directly. - -To modify a proxied component (like disabling it), create a local copy: - -```python -from fastmcp import FastMCP -from fastmcp.server import create_proxy - -proxy = create_proxy("backend_server.py") - -# Get mirrored tool -mirrored_tool = await proxy.get_tool("useful_tool") - -# Create modifiable local copy -local_tool = mirrored_tool.copy() - -# Add to your own server -my_server = FastMCP("MyServer") -my_server.add_tool(local_tool) - -# Now you can control enabled state -my_server.disable(keys={local_tool.key}) -``` - -## Performance Considerations - -Proxying introduces network latency: - -| Operation | Local | Proxied (HTTP) | -|-----------|-------|----------------| -| `list_tools()` | 1-2ms | 300-400ms | -| `call_tool()` | 1-2ms | 200-500ms | - -When mounting proxy servers, this latency affects all operations on the parent server. - -### Component List Caching - -<VersionBadge version="3.2.0" /> - -`ProxyProvider` caches the backend's component lists (tools, resources, templates, prompts) so that individual lookups — like resolving a tool by name during `call_tool` — don't require a separate backend connection. The cache stores raw component metadata and is shared across all proxy sessions; per-session visibility, auth, and transforms are still applied after cache lookup by the server layer. The cache refreshes whenever an explicit `list_*` call is made, and entries expire after a configurable TTL (default 300 seconds). - -For backends whose component lists change dynamically, disable caching by setting `cache_ttl=0`. - -```python -from fastmcp.server.providers.proxy import ProxyProvider, ProxyClient - -# Default 300s TTL -provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp")) - -# Custom TTL -provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp"), cache_ttl=60) - -# Disable caching -provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp"), cache_ttl=0) -``` - -### Session Reuse for Stateless Backends - -By default, each tool call opens a fresh MCP session to the backend. This is the safe default because it prevents state from leaking between requests. However, for stateless HTTP backends where there's no session state to protect, this overhead is unnecessary. - -You can reuse a single backend session by providing a client factory that returns the same client instance: - -```python -from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient - -base_client = ProxyClient("http://backend:8000/mcp") -shared_client = base_client.new() - -proxy = FastMCPProxy( - client_factory=lambda: shared_client, - name="ReusedSessionProxy", -) -``` - -This eliminates the MCP initialization handshake on every call, which can dramatically reduce latency under load. The `Client` uses reference counting for its session lifecycle, so concurrent callers sharing the same instance is safe. - -<Warning> -Only reuse sessions when you know the backend is stateless (e.g. stateless HTTP). For stateful backends (stdio processes, servers that track session state), use the default fresh-session behavior to avoid context mixing. -</Warning> - -## Advanced Usage - -### FastMCPProxy Class - -For explicit session control, use `FastMCPProxy` directly: - -```python -from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient - -# Custom session factory -def create_client(): - return ProxyClient("backend_server.py") - -proxy = FastMCPProxy(client_factory=create_client) -``` - -This gives you full control over session creation and reuse strategies. - -### Adding Proxied Components to Existing Server - -Mount a proxy to add components from another server: - -```python -from fastmcp import FastMCP -from fastmcp.server import create_proxy - -server = FastMCP("My Server") - -# Add local tools -@server.tool -def local_tool() -> str: - return "Local result" - -# Mount proxied tools from another server -external = create_proxy("http://external-server/mcp") -server.mount(external) - -# Now server has both local and proxied tools -``` diff --git a/docs/v3/servers/providers/skills.mdx b/docs/v3/servers/providers/skills.mdx deleted file mode 100644 index 3c810b3f2..000000000 --- a/docs/v3/servers/providers/skills.mdx +++ /dev/null @@ -1,301 +0,0 @@ ---- -title: Skills Provider -sidebarTitle: Skills -description: Expose agent skills as MCP resources -icon: wand-magic-sparkles -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Agent skills are directories containing instructions and supporting files that teach an AI assistant how to perform specific tasks. Tools like Claude Code, Cursor, and VS Code Copilot each have their own skills directories where users can add custom capabilities. The Skills Provider exposes these skill directories as MCP resources, making skills discoverable and shareable across different AI tools and clients. - -## Why Skills as Resources - -Skills live in platform-specific directories (`~/.claude/skills/`, `~/.cursor/skills/`, etc.) and typically contain a main instruction file plus supporting reference materials. When you want to share skills between tools or access them from a custom client, you need a way to discover and retrieve these files programmatically. - -The Skills Provider solves this by exposing each skill as a set of MCP resources. A client can list available skills, read the main instruction file, check the manifest to see what supporting files exist, and fetch any file it needs. This transforms local skill directories into a standardized API that works with any MCP client. - -## Quick Start - -Create a provider pointing to your skills directory, then add it to your server. - -```python -from pathlib import Path - -from fastmcp import FastMCP -from fastmcp.server.providers.skills import SkillsDirectoryProvider - -mcp = FastMCP("Skills Server") -mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills")) -``` - -Each subdirectory containing a `SKILL.md` file becomes a discoverable skill. Clients can then list resources to see available skills and read them as needed. - -```python -from fastmcp import Client - -async with Client(mcp) as client: - # List all skill resources - resources = await client.list_resources() - for r in resources: - print(r.uri) # skill://my-skill/SKILL.md, skill://my-skill/_manifest, ... - - # Read a skill's main instruction file - result = await client.read_resource("skill://my-skill/SKILL.md") - print(result[0].text) -``` - -## Skill Structure - -A skill is a directory containing a main instruction file (default: `SKILL.md`) and optionally supporting files. The directory name becomes the skill's identifier. - -``` -~/.claude/skills/ -├── pdf-processing/ -│ ├── SKILL.md # Main instructions -│ ├── reference.md # Supporting documentation -│ └── examples/ -│ └── sample.pdf -└── code-review/ - └── SKILL.md -``` - -The main file can include YAML frontmatter to provide metadata. If no frontmatter exists, the provider extracts a description from the first meaningful line of content. - -```markdown ---- -description: Process and extract information from PDF documents ---- - -# PDF Processing - -Instructions for handling PDFs... -``` - -## Resource URIs - -Each skill exposes three types of resources, all using the `skill://` URI scheme. - -The main instruction file contains the primary skill content. This is the resource clients read to understand what a skill does and how to use it. - -``` -skill://pdf-processing/SKILL.md -``` - -The manifest is a synthetic JSON resource listing all files in the skill directory with their sizes and SHA256 hashes. Clients use this to discover supporting files and verify content integrity. - -``` -skill://pdf-processing/_manifest -``` - -Reading the manifest returns structured file information. - -```json -{ - "skill": "pdf-processing", - "files": [ - {"path": "SKILL.md", "size": 1234, "hash": "sha256:abc123..."}, - {"path": "reference.md", "size": 567, "hash": "sha256:def456..."}, - {"path": "examples/sample.pdf", "size": 89012, "hash": "sha256:ghi789..."} - ] -} -``` - -Supporting files are any additional files in the skill directory. These might be reference documentation, code examples, or binary assets. - -``` -skill://pdf-processing/reference.md -skill://pdf-processing/examples/sample.pdf -``` - -## Provider Architecture - -The Skills Provider uses a two-layer architecture to handle both single skills and skill directories. - -### SkillProvider - -`SkillProvider` handles a single skill directory. It loads the main file, parses any frontmatter, scans for supporting files, and creates the appropriate resources. - -```python -from pathlib import Path - -from fastmcp import FastMCP -from fastmcp.server.providers.skills import SkillProvider - -mcp = FastMCP("Single Skill") -mcp.add_provider(SkillProvider(Path.home() / ".claude" / "skills" / "pdf-processing")) -``` - -Use `SkillProvider` when you want to expose exactly one skill, or when you need fine-grained control over individual skill configuration. - -### SkillsDirectoryProvider - -`SkillsDirectoryProvider` scans one or more root directories and creates a `SkillProvider` for each valid skill folder it finds. A folder is considered a valid skill if it contains the main file (default: `SKILL.md`). - -```python -from pathlib import Path - -from fastmcp import FastMCP -from fastmcp.server.providers.skills import SkillsDirectoryProvider - -mcp = FastMCP("Skills") -mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills")) -``` - -When scanning multiple root directories, provide them as a list. The first directory takes precedence if the same skill name appears in multiple roots. - -```python -from pathlib import Path - -from fastmcp import FastMCP -from fastmcp.server.providers.skills import SkillsDirectoryProvider - -mcp = FastMCP("Skills") -mcp.add_provider(SkillsDirectoryProvider(roots=[ - Path.cwd() / ".claude" / "skills", # Project-level skills first - Path.home() / ".claude" / "skills", # User-level fallback -])) -``` - -## Vendor Providers - -FastMCP includes pre-configured providers for popular AI coding tools. Each vendor provider extends `SkillsDirectoryProvider` with the appropriate default directory for that platform. - -| Provider | Default Directory | -|----------|-------------------| -| `ClaudeSkillsProvider` | `~/.claude/skills/` | -| `CursorSkillsProvider` | `~/.cursor/skills/` | -| `VSCodeSkillsProvider` | `~/.copilot/skills/` | -| `CodexSkillsProvider` | `/etc/codex/skills/` and `~/.codex/skills/` | -| `GeminiSkillsProvider` | `~/.gemini/skills/` | -| `GooseSkillsProvider` | `~/.config/agents/skills/` | -| `CopilotSkillsProvider` | `~/.copilot/skills/` | -| `OpenCodeSkillsProvider` | `~/.config/opencode/skills/` | - -Vendor providers accept the same configuration options as `SkillsDirectoryProvider` (except for `roots`, which is locked to the platform default). - -```python -from fastmcp import FastMCP -from fastmcp.server.providers.skills import ClaudeSkillsProvider - -mcp = FastMCP("Claude Skills") -mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/ -``` - -`CodexSkillsProvider` scans both system-level (`/etc/codex/skills/`) and user-level (`~/.codex/skills/`) directories, with system skills taking precedence. - -## Supporting Files Disclosure - -The `supporting_files` parameter controls how supporting files (everything except the main file and manifest) appear to clients. - -### Template Mode (Default) - -With `supporting_files="template"`, supporting files are accessed through a `ResourceTemplate` rather than being listed as individual resources. Clients see only the main file and manifest in `list_resources()`, then discover supporting files by reading the manifest. - -```python -from pathlib import Path - -from fastmcp.server.providers.skills import SkillsDirectoryProvider - -# Default behavior - supporting files hidden from list_resources() -provider = SkillsDirectoryProvider( - roots=Path.home() / ".claude" / "skills", - supporting_files="template", # This is the default -) -``` - -This keeps the resource list compact when skills contain many files. Clients that need supporting files read the manifest first, then request specific files by URI. - -### Resources Mode - -With `supporting_files="resources"`, every file in every skill appears as an individual resource in `list_resources()`. Clients get full enumeration upfront without needing to read manifests. - -```python -from pathlib import Path - -from fastmcp.server.providers.skills import SkillsDirectoryProvider - -# All files visible as individual resources -provider = SkillsDirectoryProvider( - roots=Path.home() / ".claude" / "skills", - supporting_files="resources", -) -``` - -Use this mode when clients need to discover all available files without additional round trips, or when integrating with tools that expect flat resource lists. - -## Reload Mode - -Enable reload mode to re-scan the skills directory on every request. Changes to skills take effect immediately without restarting the server. - -```python -from pathlib import Path - -from fastmcp.server.providers.skills import SkillsDirectoryProvider - -provider = SkillsDirectoryProvider( - roots=Path.home() / ".claude" / "skills", - reload=True, -) -``` - -With `reload=True`, the provider re-discovers skills on each `list_resources()` or `read_resource()` call. New skills appear, removed skills disappear, and modified content reflects current file state. - -<Warning> -Reload mode adds overhead to every request. Use it during development when you're actively editing skills, but disable it in production. -</Warning> - -## Client Utilities - -FastMCP provides utilities for downloading skills from any MCP server that exposes them. These are standalone functions in `fastmcp.utilities.skills`. - -### Discovering Skills - -Use `list_skills()` to see what skills are available on a server. - -```python -from fastmcp import Client -from fastmcp.utilities.skills import list_skills - -async with Client("http://skills-server/mcp") as client: - skills = await list_skills(client) - for skill in skills: - print(f"{skill.name}: {skill.description}") -``` - -### Downloading Skills - -Use `download_skill()` to download a single skill, or `sync_skills()` to download all available skills. - -```python -from pathlib import Path - -from fastmcp import Client -from fastmcp.utilities.skills import download_skill, sync_skills - -async with Client("http://skills-server/mcp") as client: - # Download one skill - path = await download_skill(client, "pdf-processing", Path.home() / ".claude" / "skills") - - # Or download all skills - paths = await sync_skills(client, Path.home() / ".claude" / "skills") -``` - -Both functions accept an `overwrite` parameter. When `False` (default), existing skills are skipped. When `True`, existing files are replaced. - -### Inspecting Manifests - -Use `get_skill_manifest()` to see what files a skill contains before downloading. - -```python -from fastmcp import Client -from fastmcp.utilities.skills import get_skill_manifest - -async with Client("http://skills-server/mcp") as client: - manifest = await get_skill_manifest(client, "pdf-processing") - for file in manifest.files: - print(f"{file.path} ({file.size} bytes, {file.hash})") -``` diff --git a/docs/v3/servers/resources.mdx b/docs/v3/servers/resources.mdx deleted file mode 100644 index c756c5ff9..000000000 --- a/docs/v3/servers/resources.mdx +++ /dev/null @@ -1,747 +0,0 @@ ---- -title: Resources & Templates -sidebarTitle: Resources -description: Expose data sources and dynamic content generators to your MCP client. -icon: folder-open ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -Resources represent data or files that an MCP client can read, and resource templates extend this concept by allowing clients to request dynamically generated resources based on parameters passed in the URI. - -FastMCP simplifies defining both static and dynamic resources, primarily using the `@mcp.resource` decorator. - -## What Are Resources? - -Resources provide read-only access to data for the LLM or client application. When a client requests a resource URI: - -1. FastMCP finds the corresponding resource definition. -2. If it's dynamic (defined by a function), the function is executed. -3. The content (text, JSON, binary data) is returned to the client. - -This allows LLMs to access files, database content, configuration, or dynamically generated information relevant to the conversation. - -## Resources - -### The `@resource` Decorator - -The most common way to define a resource is by decorating a Python function. The decorator requires the resource's unique URI. - -```python -import json -from fastmcp import FastMCP - -mcp = FastMCP(name="DataServer") - -# Basic dynamic resource returning a string -@mcp.resource("resource://greeting") -def get_greeting() -> str: - """Provides a simple greeting message.""" - return "Hello from FastMCP Resources!" - -# Resource returning JSON data -@mcp.resource("data://config") -def get_config() -> str: - """Provides application configuration as JSON.""" - return json.dumps({ - "theme": "dark", - "version": "1.2.0", - "features": ["tools", "resources"], - }) -``` - -**Key Concepts:** - -* **URI:** The first argument to `@resource` is the unique URI (e.g., `"resource://greeting"`) clients use to request this data. -* **Lazy Loading:** The decorated function (`get_greeting`, `get_config`) is only executed when a client specifically requests that resource URI via `resources/read`. -* **Inferred Metadata:** By default: - * Resource Name: Taken from the function name (`get_greeting`). - * Resource Description: Taken from the function's docstring. - -#### Decorator Arguments - -You can customize the resource's properties using arguments in the `@mcp.resource` decorator: - -```python -from fastmcp import FastMCP - -mcp = FastMCP(name="DataServer") - -# Example specifying metadata -@mcp.resource( - uri="data://app-status", # Explicit URI (required) - name="ApplicationStatus", # Custom name - description="Provides the current status of the application.", # Custom description - mime_type="application/json", # Explicit MIME type - tags={"monitoring", "status"}, # Categorization tags - meta={"version": "2.1", "team": "infrastructure"} # Custom metadata -) -def get_application_status() -> str: - """Internal function description (ignored if description is provided above).""" - return json.dumps({"status": "ok", "uptime": 12345, "version": mcp.settings.version}) -``` - -<Card icon="code" title="@resource Decorator Arguments"> -<ParamField body="uri" type="str" required> - The unique identifier for the resource -</ParamField> - -<ParamField body="name" type="str | None"> - A human-readable name. If not provided, defaults to function name -</ParamField> - -<ParamField body="description" type="str | None"> - Explanation of the resource. If not provided, defaults to docstring -</ParamField> - -<ParamField body="mime_type" type="str | None"> - Specifies the content type. FastMCP often infers a default like `text/plain` or `application/json`, but explicit is better for non-text types -</ParamField> - -<ParamField body="tags" type="set[str] | None"> - A set of strings used to categorize the resource. These can be used by the server and, in some cases, by clients to filter or group available resources. -</ParamField> - -<ParamField body="enabled" type="bool" default="True"> - <Warning>Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead.</Warning> - A boolean to enable or disable the resource. See [Component Visibility](#component-visibility) for the recommended approach. -</ParamField> - -<ParamField body="icons" type="list[Icon] | None"> - <VersionBadge version="2.13.0" /> - - Optional list of icon representations for this resource or template. See [Icons](/servers/icons) for detailed examples -</ParamField> - -<ParamField body="annotations" type="Annotations | dict | None"> - An optional `Annotations` object or dictionary to add additional metadata about the resource. - <Expandable title="Annotations attributes"> - <ParamField body="readOnlyHint" type="bool | None"> - If true, the resource is read-only and does not modify its environment. - </ParamField> - <ParamField body="idempotentHint" type="bool | None"> - If true, reading the resource repeatedly will have no additional effect on its environment. - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="meta" type="dict[str, Any] | None"> - <VersionBadge version="2.11.0" /> - - Optional meta information about the resource. This data is passed through to the MCP client as the `meta` field of the client-side resource object and can be used for custom metadata, versioning, or other application-specific purposes. -</ParamField> - -<ParamField body="version" type="str | int | None"> - <VersionBadge version="3.0.0" /> - - Optional version identifier for this resource. See [Versioning](/servers/versioning) for details. -</ParamField> -</Card> - -#### Using with Methods - -For decorating instance or class methods, use the standalone `@resource` decorator and register the bound method. See [Tools: Using with Methods](/servers/tools#using-with-methods) for the pattern. - -### Return Values - -Resource functions must return one of three types: - -- **`str`**: Sent as `TextResourceContents` (with `mime_type="text/plain"` by default). -- **`bytes`**: Base64 encoded and sent as `BlobResourceContents`. You should specify an appropriate `mime_type` (e.g., `"image/png"`, `"application/octet-stream"`). -- **`ResourceResult`**: Full control over contents, MIME types, and metadata. See [ResourceResult](#resourceresult) below. - -<Note> -To return structured data like dicts or lists, serialize them to JSON strings using `json.dumps()`. This explicit approach ensures your type checker catches errors during development rather than at runtime when a client reads the resource. -</Note> - -#### ResourceResult - -<VersionBadge version="3.0.0" /> - -`ResourceResult` gives you explicit control over resource responses: multiple content items, per-item MIME types, and metadata at both the item and result level. - -```python -from fastmcp import FastMCP -from fastmcp.resources import ResourceResult, ResourceContent - -mcp = FastMCP() - -@mcp.resource("data://users") -def get_users() -> ResourceResult: - return ResourceResult( - contents=[ - ResourceContent(content='[{"id": 1}]', mime_type="application/json"), - ResourceContent(content="# Users\n...", mime_type="text/markdown"), - ], - meta={"total": 1} - ) -``` - -`ResourceContent` accepts three fields: - -**`content`** - The actual resource content. Can be `str` (text content) or `bytes` (binary content). This is the data that will be returned to the client. - -**`mime_type`** - Optional MIME type for the content. Defaults to `"text/plain"` for string content and `"application/octet-stream"` for binary content. - -**`meta`** - Optional metadata dictionary that will be included in the MCP response's `meta` field. Use this for runtime metadata like Content Security Policy headers, caching hints, or other client-specific data. - -For simple cases, you can pass `str` or `bytes` directly to `ResourceResult`: - -```python -return ResourceResult("plain text") # auto-converts to ResourceContent -return ResourceResult(b"\x00\x01\x02") # binary content -``` - -<Card title="ResourceResult"> -<ParamField body="contents" type="str | bytes | list[ResourceContent]" required> - Content to return. Strings and bytes are wrapped in a single `ResourceContent`. Use a list of `ResourceContent` for multiple items or custom MIME types. -</ParamField> -<ParamField body="meta" type="dict[str, Any] | None"> - Result-level metadata, included in the MCP response's `_meta` field. -</ParamField> -</Card> - -<Card title="ResourceContent"> -<ParamField body="content" type="Any" required> - The content data. Strings and bytes pass through directly. Other types (dict, list, BaseModel) are automatically JSON-serialized. -</ParamField> -<ParamField body="mime_type" type="str | None"> - MIME type. Defaults to `text/plain` for strings, `application/octet-stream` for bytes, `application/json` for serialized objects. -</ParamField> -<ParamField body="meta" type="dict[str, Any] | None"> - Item-level metadata for this specific content. -</ParamField> -</Card> - -### Component Visibility - -<VersionBadge version="3.0.0" /> - -You can control which resources are enabled for clients using server-level enabled control. Disabled resources don't appear in `list_resources` and can't be read. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -@mcp.resource("data://public", tags={"public"}) -def get_public(): return "public" - -@mcp.resource("data://secret", tags={"internal"}) -def get_secret(): return "secret" - -# Disable specific resources by key -mcp.disable(keys={"resource:data://secret"}) - -# Disable resources by tag -mcp.disable(tags={"internal"}) - -# Or use allowlist mode - only enable resources with specific tags -mcp.enable(tags={"public"}, only=True) -``` - -See [Visibility](/servers/visibility) for the complete visibility control API including key formats, tag-based filtering, and provider-level control. - - -### Accessing MCP Context - -<VersionBadge version="2.2.5" /> - -Resources and resource templates can access additional MCP information and features through the `Context` object. To access it, add a parameter to your resource function with a type annotation of `Context`: - -```python {6, 14} -from fastmcp import FastMCP, Context - -mcp = FastMCP(name="DataServer") - -@mcp.resource("resource://system-status") -async def get_system_status(ctx: Context) -> str: - """Provides system status information.""" - return json.dumps({ - "status": "operational", - "request_id": ctx.request_id - }) - -@mcp.resource("resource://{name}/details") -async def get_details(name: str, ctx: Context) -> str: - """Get details for a specific name.""" - return json.dumps({ - "name": name, - "accessed_at": ctx.request_id - }) -``` - -For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). - - -### Async Resources - -FastMCP supports both `async def` and regular `def` resource functions. Synchronous functions automatically run in a threadpool to avoid blocking the event loop. - -For I/O-bound operations, async functions are more efficient: - -```python -import aiofiles -from fastmcp import FastMCP - -mcp = FastMCP(name="DataServer") - -@mcp.resource("file:///app/data/important_log.txt", mime_type="text/plain") -async def read_important_log() -> str: - """Reads content from a specific log file asynchronously.""" - try: - async with aiofiles.open("/app/data/important_log.txt", mode="r") as f: - content = await f.read() - return content - except FileNotFoundError: - return "Log file not found." -``` - - -### Resource Classes - -While `@mcp.resource` is ideal for dynamic content, you can directly register pre-defined resources (like static files or simple text) using `mcp.add_resource()` and concrete `Resource` subclasses. - -```python -from pathlib import Path -from fastmcp import FastMCP -from fastmcp.resources import FileResource, TextResource, DirectoryResource - -mcp = FastMCP(name="DataServer") - -# 1. Exposing a static file directly -readme_path = Path("./README.md").resolve() -if readme_path.exists(): - # Use a file:// URI scheme - readme_resource = FileResource( - uri=f"file://{readme_path.as_posix()}", - path=readme_path, # Path to the actual file - name="README File", - description="The project's README.", - mime_type="text/markdown", - tags={"documentation"} - ) - mcp.add_resource(readme_resource) - -# 2. Exposing simple, predefined text -notice_resource = TextResource( - uri="resource://notice", - name="Important Notice", - text="System maintenance scheduled for Sunday.", - tags={"notification"} -) -mcp.add_resource(notice_resource) - -# 3. Exposing a directory listing -data_dir_path = Path("./app_data").resolve() -if data_dir_path.is_dir(): - data_listing_resource = DirectoryResource( - uri="resource://data-files", - path=data_dir_path, # Path to the directory - name="Data Directory Listing", - description="Lists files available in the data directory.", - recursive=False # Set to True to list subdirectories - ) - mcp.add_resource(data_listing_resource) # Returns JSON list of files -``` - -**Common Resource Classes:** - -- `TextResource`: For simple string content. -- `BinaryResource`: For raw `bytes` content. -- `FileResource`: Reads content from a local file path. Handles text/binary modes, encoding, and lazy reading. -- `HttpResource`: Fetches content from an HTTP(S) URL (requires `httpx`). -- `DirectoryResource`: Lists files in a local directory (returns JSON). -- (`FunctionResource`: Internal class used by `@mcp.resource`). - -Use these when the content is static or sourced directly from a file/URL, bypassing the need for a dedicated Python function. - -### Notifications - -<VersionBadge version="2.9.1" /> - -FastMCP automatically sends `notifications/resources/list_changed` notifications to connected clients when resources or templates are added, enabled, or disabled. This allows clients to stay up-to-date with the current resource set without manually polling for changes. - -```python -@mcp.resource("data://example") -def example_resource() -> str: - return "Hello!" - -# These operations trigger notifications: -mcp.add_resource(example_resource) # Sends resources/list_changed notification -mcp.disable(keys={"resource:data://example"}) # Sends resources/list_changed notification -mcp.enable(keys={"resource:data://example"}) # Sends resources/list_changed notification -``` - -Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications. - -Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their resource lists or update their interfaces. - -### Annotations - -<VersionBadge version="2.11.0" /> - -FastMCP allows you to add specialized metadata to your resources through annotations. These annotations communicate how resources behave to client applications without consuming token context in LLM prompts. - -Annotations serve several purposes in client applications: -- Indicating whether resources are read-only or may have side effects -- Describing the safety profile of resources (idempotent vs. non-idempotent) -- Helping clients optimize caching and access patterns - -You can add annotations to a resource using the `annotations` parameter in the `@mcp.resource` decorator: - -```python -@mcp.resource( - "data://config", - annotations={ - "readOnlyHint": True, - "idempotentHint": True - } -) -def get_config() -> str: - """Get application configuration.""" - return json.dumps({"version": "1.0", "debug": False}) -``` - -FastMCP supports these standard annotations: - -| Annotation | Type | Default | Purpose | -| :--------- | :--- | :------ | :------ | -| `readOnlyHint` | boolean | true | Indicates if the resource only provides data without side effects | -| `idempotentHint` | boolean | true | Indicates if repeated reads have the same effect as a single read | - -Remember that annotations help make better user experiences but should be treated as advisory hints. They help client applications present appropriate UI elements and optimize access patterns, but won't enforce behavior on their own. Always focus on making your annotations accurately represent what your resource actually does. - -## Resource Templates - -Resource Templates allow clients to request resources whose content depends on parameters embedded in the URI. Define a template using the **same `@mcp.resource` decorator**, but include `{parameter_name}` placeholders in the URI string and add corresponding arguments to your function signature. - -Resource templates share most configuration options with regular resources (name, description, mime_type, tags, annotations), but add the ability to define URI parameters that map to function parameters. - -Resource templates generate a new resource for each unique set of parameters, which means that resources can be dynamically created on-demand. For example, if the resource template `"user://profile/{name}"` is registered, MCP clients could request `"user://profile/ford"` or `"user://profile/marvin"` to retrieve either of those two user profiles as resources, without having to register each resource individually. - -<Tip> -Functions with `*args` are not supported as resource templates. However, unlike tools and prompts, resource templates do support `**kwargs` because the URI template defines specific parameter names that will be collected and passed as keyword arguments. -</Tip> - -Here is a complete example that shows how to define two resource templates: - -```python -import json -from fastmcp import FastMCP - -mcp = FastMCP(name="DataServer") - -# Template URI includes {city} placeholder -@mcp.resource("weather://{city}/current") -def get_weather(city: str) -> str: - """Provides weather information for a specific city.""" - return json.dumps({ - "city": city.capitalize(), - "temperature": 22, - "condition": "Sunny", - "unit": "celsius" - }) - -# Template with multiple parameters and annotations -@mcp.resource( - "repos://{owner}/{repo}/info", - annotations={ - "readOnlyHint": True, - "idempotentHint": True - } -) -def get_repo_info(owner: str, repo: str) -> str: - """Retrieves information about a GitHub repository.""" - return json.dumps({ - "owner": owner, - "name": repo, - "full_name": f"{owner}/{repo}", - "stars": 120, - "forks": 48 - }) -``` - -With these two templates defined, clients can request a variety of resources: -- `weather://london/current` → Returns weather for London -- `weather://paris/current` → Returns weather for Paris -- `repos://PrefectHQ/fastmcp/info` → Returns info about the PrefectHQ/fastmcp repository -- `repos://prefecthq/prefect/info` → Returns info about the prefecthq/prefect repository - -### RFC 6570 URI Templates - - -FastMCP implements [RFC 6570 URI Templates](https://datatracker.ietf.org/doc/html/rfc6570) for resource templates, providing a standardized way to define parameterized URIs. This includes support for simple expansion, wildcard path parameters, and form-style query parameters. - -#### Wildcard Parameters - -<VersionBadge version="2.2.4" /> - -Resource templates support wildcard parameters that can match multiple path segments. Standard parameters (`{param}`) match a single URI segment before decoding and do not cross literal "/" boundaries in the request URI. Wildcard parameters (`{param*}`) can capture multiple segments including slashes. Wildcards capture all subsequent path segments *up until* the defined part of the URI template (whether literal or another parameter). This allows you to have multiple wildcard parameters in a single URI template. - -```python {15, 23} -from fastmcp import FastMCP - -mcp = FastMCP(name="DataServer") - - -# Standard parameter only matches one segment -@mcp.resource("files://{filename}") -def get_file(filename: str) -> str: - """Retrieves a file by name.""" - # Will only match files://<single-segment> - return f"File content for: {filename}" - - -# Wildcard parameter can match multiple segments -@mcp.resource("path://{filepath*}") -def get_path_content(filepath: str) -> str: - """Retrieves content at a specific path.""" - # Can match path://docs/server/resources.mdx - return f"Content at path: {filepath}" - - -# Mixing standard and wildcard parameters -@mcp.resource("repo://{owner}/{path*}/template.py") -def get_template_file(owner: str, path: str) -> dict: - """Retrieves a file from a specific repository and path, but - only if the resource ends with `template.py`""" - # Can match repo://PrefectHQ/fastmcp/src/resources/template.py - return { - "owner": owner, - "path": path + "/template.py", - "content": f"File at {path}/template.py in {owner}'s repository" - } -``` - -Wildcard parameters are useful when: - -- Working with file paths or hierarchical data -- Creating APIs that need to capture variable-length path segments -- Building URL-like patterns similar to REST APIs - -Note that like regular parameters, each wildcard parameter must still be a named parameter in your function signature, and all required function parameters must appear in the URI template. - -#### Filesystem Path Safety - -Template parameters are decoded before your function receives them. A standard `{filename}` parameter matches one URI segment before decoding, so a request like `files://a%2Fb` passes `filename="a/b"` to the handler. Treat template values as untrusted decoded URI data whenever they determine filesystem paths. - -Validate the final resolved path against an allowed root before reading: - -```python -from pathlib import Path - -from fastmcp import FastMCP -from fastmcp.exceptions import ResourceError - -mcp = FastMCP(name="DocsServer") -DOCS_ROOT = Path("docs").resolve() - - -@mcp.resource("docs://{filename}") -def read_doc(filename: str) -> str: - requested_path = (DOCS_ROOT / filename).resolve() - - if not requested_path.is_relative_to(DOCS_ROOT) or not requested_path.is_file(): - raise ResourceError("Document not found") - - return requested_path.read_text(encoding="utf-8") -``` - -Use wildcard parameters (`{path*}`) for resources whose URI shape intentionally includes slashes, and apply the same containment check before accessing the filesystem. - -#### Query Parameters - -<VersionBadge version="2.13.0" /> - -FastMCP supports RFC 6570 form-style query parameters using the `{?param1,param2}` syntax. Query parameters provide a clean way to pass optional configuration to resources without cluttering the path. - -Query parameters must be optional function parameters (have default values), while path parameters map to required function parameters. This enforces a clear separation: required data goes in the path, optional configuration in query params. - -```python -from fastmcp import FastMCP - -mcp = FastMCP(name="DataServer") - -# Basic query parameters -@mcp.resource("data://{id}{?format}") -def get_data(id: str, format: str = "json") -> str: - """Retrieve data in specified format.""" - if format == "xml": - return f"<data id='{id}' />" - return f'{{"id": "{id}"}}' - -# Multiple query parameters with type coercion -@mcp.resource("api://{endpoint}{?version,limit,offset}") -def call_api(endpoint: str, version: int = 1, limit: int = 10, offset: int = 0) -> dict: - """Call API endpoint with pagination.""" - return { - "endpoint": endpoint, - "version": version, - "limit": limit, - "offset": offset, - "results": fetch_results(endpoint, version, limit, offset) - } - -# Query parameters with wildcards -@mcp.resource("files://{path*}{?encoding,lines}") -def read_file(path: str, encoding: str = "utf-8", lines: int = 100) -> str: - """Read file with optional encoding and line limit.""" - return read_file_content(path, encoding, lines) -``` - -**Example requests:** -- `data://123` → Uses default format `"json"` -- `data://123?format=xml` → Uses format `"xml"` -- `api://users?version=2&limit=50` → `version=2, limit=50, offset=0` -- `files://src/main.py?encoding=ascii&lines=50` → Custom encoding and line limit - -FastMCP automatically coerces query parameter string values to the correct types based on your function's type hints (`int`, `float`, `bool`, `str`). - -**Query parameters vs. hidden defaults:** - -Query parameters expose optional configuration to clients. To hide optional parameters from clients entirely (always use defaults), simply omit them from the URI template: - -```python -# Clients CAN override max_results via query string -@mcp.resource("search://{query}{?max_results}") -def search_configurable(query: str, max_results: int = 10) -> dict: - return {"query": query, "limit": max_results} - -# Clients CANNOT override max_results (not in URI template) -@mcp.resource("search://{query}") -def search_fixed(query: str, max_results: int = 10) -> dict: - return {"query": query, "limit": max_results} -``` - -### Template Parameter Rules - -<VersionBadge version="2.2.0" /> - -FastMCP enforces these validation rules when creating resource templates: - -1. **Required function parameters** (no default values) must appear in the URI path template -2. **Query parameters** (specified with `{?param}` syntax) must be optional function parameters with default values -3. **All URI template parameters** (path and query) must exist as function parameters - -Optional function parameters (those with default values) can be: -- Included as query parameters (`{?param}`) - clients can override via query string -- Omitted from URI template - always uses default value, not exposed to clients -- Used in alternative path templates - enables multiple ways to access the same resource - -**Multiple templates for one function:** - -Create multiple resource templates that expose the same function through different URI patterns by manually applying decorators: - -```python -from fastmcp import FastMCP - -mcp = FastMCP(name="DataServer") - -# Define a user lookup function that can be accessed by different identifiers -def lookup_user(name: str | None = None, email: str | None = None) -> dict: - """Look up a user by either name or email.""" - if email: - return find_user_by_email(email) # pseudocode - elif name: - return find_user_by_name(name) # pseudocode - else: - return {"error": "No lookup parameters provided"} - -# Manually apply multiple decorators to the same function -mcp.resource("users://email/{email}")(lookup_user) -mcp.resource("users://name/{name}")(lookup_user) -``` - -Now an LLM or client can retrieve user information in two different ways: -- `users://email/alice@example.com` → Looks up user by email (with name=None) -- `users://name/Bob` → Looks up user by name (with email=None) - -This approach allows a single function to be registered with multiple URI patterns while keeping the implementation clean and straightforward. - -Templates provide a powerful way to expose parameterized data access points following REST-like principles. - -## Error Handling - -<VersionBadge version="2.4.1" /> - -If your resource function encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ResourceError`. - -By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately. - -If you want to mask internal error details for security reasons, you can: - -1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance: -```python -mcp = FastMCP(name="SecureServer", mask_error_details=True) -``` - -2. Or use `ResourceError` to explicitly control what error information is sent to clients: -```python -from fastmcp import FastMCP -from fastmcp.exceptions import ResourceError - -mcp = FastMCP(name="DataServer") - -@mcp.resource("resource://safe-error") -def fail_with_details() -> str: - """This resource provides detailed error information.""" - # ResourceError contents are always sent back to clients, - # regardless of mask_error_details setting - raise ResourceError("Unable to retrieve data: file not found") - -@mcp.resource("resource://masked-error") -def fail_with_masked_details() -> str: - """This resource masks internal error details when mask_error_details=True.""" - # This message would be masked if mask_error_details=True - raise ValueError("Sensitive internal file path: /etc/secrets.conf") - -@mcp.resource("data://{id}") -def get_data_by_id(id: str) -> dict: - """Template resources also support the same error handling pattern.""" - if id == "secure": - raise ValueError("Cannot access secure data") - elif id == "missing": - raise ResourceError("Data ID 'missing' not found in database") - return {"id": id, "value": "data"} -``` - -When `mask_error_details=True`, only error messages from `ResourceError` will include details, other exceptions will be converted to a generic message. - -## Server Behavior - -### Duplicate Resources - -<VersionBadge version="2.1.0" /> - -You can configure how the FastMCP server handles attempts to register multiple resources or templates with the same URI. Use the `on_duplicate_resources` setting during `FastMCP` initialization. - -```python -from fastmcp import FastMCP - -mcp = FastMCP( - name="ResourceServer", - on_duplicate_resources="error" # Raise error on duplicates -) - -@mcp.resource("data://config") -def get_config_v1(): return {"version": 1} - -# This registration attempt will raise a ValueError because -# "data://config" is already registered and the behavior is "error". -# @mcp.resource("data://config") -# def get_config_v2(): return {"version": 2} -``` - -The duplicate behavior options are: - -- `"warn"` (default): Logs a warning, and the new resource/template replaces the old one. -- `"error"`: Raises a `ValueError`, preventing the duplicate registration. -- `"replace"`: Silently replaces the existing resource/template with the new one. -- `"ignore"`: Keeps the original resource/template and ignores the new registration attempt. - -## Versioning - -<VersionBadge version="3.0.0" /> - -Resources and resource templates support versioning, allowing you to maintain multiple implementations under the same URI while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns. diff --git a/docs/v3/servers/sampling.mdx b/docs/v3/servers/sampling.mdx deleted file mode 100644 index 8ea479eb0..000000000 --- a/docs/v3/servers/sampling.mdx +++ /dev/null @@ -1,573 +0,0 @@ ---- -title: Sampling -sidebarTitle: Sampling -description: Request LLM text generation from the client or a configured provider through the MCP context. -icon: robot ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.0.0" /> - -LLM sampling allows your MCP tools to request text generation from an LLM during execution. This enables tools to leverage AI capabilities for analysis, generation, reasoning, and more—without the client needing to orchestrate multiple calls. - -By default, sampling requests are routed to the client's LLM. You can also configure a fallback handler to use a specific provider (like OpenAI) when the client doesn't support sampling, or to always use your own LLM regardless of client capabilities. - -## Overview - -The simplest use of sampling is passing a prompt string to `ctx.sample()`. The method sends the prompt to the LLM, waits for the complete response, and returns a `SamplingResult`. You can access the generated text through the `.text` attribute. - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def summarize(content: str, ctx: Context) -> str: - """Generate a summary of the provided content.""" - result = await ctx.sample(f"Please summarize this:\n\n{content}") - return result.text or "" -``` - -The `SamplingResult` also provides `.result` (identical to `.text` for plain text responses) and `.history` containing the full message exchange—useful if you need to continue the conversation or debug the interaction. - -### System Prompts - -System prompts let you establish the LLM's role and behavioral guidelines before it processes your request. This is useful for controlling tone, enforcing constraints, or providing context that shouldn't clutter the user-facing prompt. - -````python -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def generate_code(concept: str, ctx: Context) -> str: - """Generate a Python code example for a concept.""" - result = await ctx.sample( - messages=f"Write a Python example demonstrating '{concept}'.", - system_prompt=( - "You are an expert Python programmer. " - "Provide concise, working code without explanations." - ), - temperature=0.7, - max_tokens=300 - ) - return f"```python\n{result.text}\n```" -```` - -The `temperature` parameter controls randomness—higher values (up to 1.0) produce more varied outputs, while lower values make responses more deterministic. The `max_tokens` parameter limits response length. - -### Model Preferences - -Model preferences let you hint at which LLM the client should use for a request. You can pass a single model name or a list of preferences in priority order. These are hints rather than requirements—the actual model used depends on what the client has available. - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def technical_analysis(data: str, ctx: Context) -> str: - """Analyze data using a reasoning-focused model.""" - result = await ctx.sample( - messages=f"Analyze this data:\n\n{data}", - model_preferences=["claude-opus-4-5", "gpt-5-2"], - temperature=0.2, - ) - return result.text or "" -``` - -Use model preferences when different tasks benefit from different model characteristics. Creative writing might prefer faster models with higher temperature, while complex analysis might benefit from larger reasoning-focused models. - -### Multi-Turn Conversations - -For requests that need conversational context, construct a list of `SamplingMessage` objects representing the conversation history. Each message has a `role` ("user" or "assistant") and `content` (a `TextContent` object). - -```python -from mcp.types import SamplingMessage, TextContent -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def contextual_analysis(query: str, data: str, ctx: Context) -> str: - """Analyze data with conversational context.""" - messages = [ - SamplingMessage( - role="user", - content=TextContent(type="text", text=f"Here's my data: {data}"), - ), - SamplingMessage( - role="assistant", - content=TextContent(type="text", text="I see the data. What would you like to know?"), - ), - SamplingMessage( - role="user", - content=TextContent(type="text", text=query), - ), - ] - result = await ctx.sample(messages=messages) - return result.text or "" -``` - -The LLM receives the full conversation thread and responds with awareness of the preceding context. - -### Fallback Handlers - -Client support for sampling is optional—some clients may not implement it. To ensure your tools work regardless of client capabilities, configure a `sampling_handler` that sends requests directly to an LLM provider. - -FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format. - -<Note> -Install handlers with `pip install fastmcp[openai]` or `pip install fastmcp[anthropic]`. -</Note> - -```python -from fastmcp import FastMCP -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler - -server = FastMCP( - name="My Server", - sampling_handler=OpenAISamplingHandler(default_model="gpt-4o-mini"), - sampling_handler_behavior="fallback", -) -``` - -The `sampling_handler_behavior` parameter controls when the handler is used: - -- **`"fallback"`** (default): Use the handler only when the client doesn't support sampling. This lets capable clients use their own LLM while ensuring your tools still work with clients that lack sampling support. -- **`"always"`**: Always use the handler, bypassing the client entirely. Use this when you need guaranteed control over which LLM processes requests—for cost control, compliance requirements, or when specific model characteristics are essential. - -## Structured Output - -<VersionBadge version="2.14.1" /> - -When you need validated, typed data instead of free-form text, use the `result_type` parameter. FastMCP ensures the LLM returns data matching your type, handling validation and retries automatically. - -The `result_type` parameter accepts Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. When you specify a result type, FastMCP automatically creates a `final_response` tool that the LLM calls to provide its response. If validation fails, the error is sent back to the LLM for retry. - -```python -from pydantic import BaseModel -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -class SentimentResult(BaseModel): - sentiment: str - confidence: float - reasoning: str - -@mcp.tool -async def analyze_sentiment(text: str, ctx: Context) -> SentimentResult: - """Analyze text sentiment with structured output.""" - result = await ctx.sample( - messages=f"Analyze the sentiment of: {text}", - result_type=SentimentResult, - ) - return result.result # A validated SentimentResult object -``` - -When you call this tool, the LLM returns a structured response that FastMCP validates against your Pydantic model. You access the validated object through `result.result`, while `result.text` contains the JSON representation. - -### Structured Output with Tools - -Combine structured output with tools for agentic workflows that return validated data. The LLM uses your tools to gather information, then returns a response matching your type. - -```python -from pydantic import BaseModel -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -def search(query: str) -> str: - """Search the web for information.""" - return f"Results for: {query}" - -def fetch_url(url: str) -> str: - """Fetch content from a URL.""" - return f"Content from: {url}" - -class ResearchResult(BaseModel): - summary: str - sources: list[str] - confidence: float - -@mcp.tool -async def research(topic: str, ctx: Context) -> ResearchResult: - """Research a topic and return structured findings.""" - result = await ctx.sample( - messages=f"Research: {topic}", - tools=[search, fetch_url], - result_type=ResearchResult, - ) - return result.result -``` - -<Note> -Structured output with automatic validation only applies to `sample()`. With `sample_step()`, you must manage structured output yourself. -</Note> - -## Tool Use - -<VersionBadge version="2.14.1" /> - -Sampling with tools enables agentic workflows where the LLM can call functions to gather information before responding. This implements [SEP-1577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577), allowing the LLM to autonomously orchestrate multi-step operations. - -Pass Python functions to the `tools` parameter, and FastMCP handles the execution loop automatically—calling tools, returning results to the LLM, and continuing until the LLM provides a final response. - -### Defining Tools - -Define regular Python functions with type hints and docstrings. FastMCP extracts the function's name, docstring, and parameter types to create tool schemas that the LLM can understand. - -```python -from fastmcp import FastMCP, Context - -def search(query: str) -> str: - """Search the web for information.""" - return f"Results for: {query}" - -def get_time() -> str: - """Get the current time.""" - from datetime import datetime - return datetime.now().strftime("%H:%M:%S") - -mcp = FastMCP() - -@mcp.tool -async def research(question: str, ctx: Context) -> str: - """Answer questions using available tools.""" - result = await ctx.sample( - messages=question, - tools=[search, get_time], - ) - return result.text or "" -``` - -The LLM sees each function's signature and docstring, using this information to decide when and how to call them. Tool errors are caught and sent back to the LLM, allowing it to recover gracefully. An internal safety limit prevents infinite loops. - -### Custom Tool Definitions - -For custom names or descriptions, use `SamplingTool.from_function()`: - -```python -from fastmcp.server.sampling import SamplingTool - -tool = SamplingTool.from_function( - my_func, - name="custom_name", - description="Custom description" -) - -result = await ctx.sample(messages="...", tools=[tool]) -``` - -### Error Handling - -By default, when a sampling tool raises an exception, the error message (including details) is sent back to the LLM so it can attempt recovery. To prevent sensitive information from leaking to the LLM, use the `mask_error_details` parameter: - -```python -result = await ctx.sample( - messages=question, - tools=[search], - mask_error_details=True, # Generic error messages only -) -``` - -When `mask_error_details=True`, tool errors become generic messages like `"Error executing tool 'search'"` instead of exposing stack traces or internal details. - -To intentionally provide specific error messages to the LLM regardless of masking, raise `ToolError`: - -```python -from fastmcp.exceptions import ToolError - -def search(query: str) -> str: - """Search for information.""" - if not query.strip(): - raise ToolError("Search query cannot be empty") - return f"Results for: {query}" -``` - -`ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle. - -### Concurrent Tool Execution - -By default, tools execute sequentially — one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`: - -```python -result = await ctx.sample( - messages="Research these three topics", - tools=[search, fetch_url], - tool_concurrency=0, # Unlimited parallel execution -) -``` - -The `tool_concurrency` parameter controls how many tools run at once: - -- **`None`** (default): Sequential execution -- **`0`**: Unlimited parallel execution -- **`N > 0`**: Execute at most N tools concurrently - -For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`: - -```python -from fastmcp.server.sampling import SamplingTool - -db_writer = SamplingTool.from_function( - write_to_db, - sequential=True, # Forces all tools in the batch to run sequentially -) - -result = await ctx.sample( - messages="Process this data", - tools=[search, db_writer], - tool_concurrency=0, # Would be parallel, but db_writer forces sequential -) -``` - -<Note> -When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee — if one tool needs ordering, all tools in that batch respect it. -</Note> - -### Client Requirements - -<Note> -Sampling with tools requires the client to advertise the `sampling.tools` capability. FastMCP clients do this automatically. For external clients that don't support tool-enabled sampling, configure a fallback handler with `sampling_handler_behavior="always"`. -</Note> - -## Advanced Control - -<VersionBadge version="2.14.1" /> - -While `sample()` handles the tool execution loop automatically, some scenarios require fine-grained control over each step. The `sample_step()` method makes a single LLM call and returns a `SampleStep` containing the response and updated history. - -Unlike `sample()`, `sample_step()` is stateless—it doesn't remember previous calls. You control the conversation by passing the full message history each time. The returned `step.history` includes all messages up through the current response, making it easy to continue the loop. - -Use `sample_step()` when you need to: - -- Inspect tool calls before they execute -- Implement custom termination conditions -- Add logging, metrics, or checkpointing between steps -- Build custom agentic loops with domain-specific logic - -### Basic Loop - -By default, `sample_step()` executes any tool calls and includes the results in the history. Call it in a loop, passing the updated history each time, until a stop condition is met. - -```python -from mcp.types import SamplingMessage -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -def search(query: str) -> str: - return f"Results for: {query}" - -def get_time() -> str: - return "12:00 PM" - -@mcp.tool -async def controlled_agent(question: str, ctx: Context) -> str: - """Agent with manual loop control.""" - messages: list[str | SamplingMessage] = [question] - - while True: - step = await ctx.sample_step( - messages=messages, - tools=[search, get_time], - ) - - if step.is_tool_use: - # Tools already executed (execute_tools=True by default) - for call in step.tool_calls: - print(f"Called tool: {call.name}") - - if not step.is_tool_use: - return step.text or "" - - messages = step.history -``` - -### SampleStep Properties - -Each `SampleStep` provides information about what the LLM returned: - -| Property | Description | -|----------|-------------| -| `step.is_tool_use` | True if the LLM requested tool calls | -| `step.tool_calls` | List of tool calls requested (if any) | -| `step.text` | The text content (if any) | -| `step.history` | All messages exchanged so far | - -The contents of `step.history` depend on `execute_tools`: -- **`execute_tools=True`** (default): Includes tool results, ready for the next iteration -- **`execute_tools=False`**: Includes the assistant's tool request, but you add results yourself - -### Manual Tool Execution - -Set `execute_tools=False` to handle tool execution yourself. When disabled, `step.history` contains the user message and the assistant's response with tool calls—but no tool results. You execute the tools and append the results as a user message. - -```python -from mcp.types import SamplingMessage, ToolResultContent, TextContent -from fastmcp import FastMCP, Context - -mcp = FastMCP() - -@mcp.tool -async def research(question: str, ctx: Context) -> str: - """Research with manual tool handling.""" - - def search(query: str) -> str: - return f"Results for: {query}" - - def get_time() -> str: - return "12:00 PM" - - tools = {"search": search, "get_time": get_time} - messages: list[SamplingMessage] = [question] - - while True: - step = await ctx.sample_step( - messages=messages, - tools=list(tools.values()), - execute_tools=False, - ) - - if not step.is_tool_use: - return step.text or "" - - # Execute tools and collect results - tool_results = [] - for call in step.tool_calls: - fn = tools[call.name] - result = fn(**call.input) - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=call.id, - content=[TextContent(type="text", text=result)], - ) - ) - - messages = list(step.history) - messages.append(SamplingMessage(role="user", content=tool_results)) -``` - -To report an error to the LLM, set `isError=True` on the tool result: - -```python -tool_result = ToolResultContent( - type="tool_result", - toolUseId=call.id, - content=[TextContent(type="text", text="Permission denied")], - isError=True, -) -``` - -## Method Reference - -<Card icon="code" title="ctx.sample()"> -<ResponseField name="ctx.sample" type="async method"> - Request text generation from the LLM, running to completion automatically. - - <Expandable title="Parameters"> - <ResponseField name="messages" type="str | list[str | SamplingMessage]"> - The prompt to send. Can be a simple string or a list of messages for multi-turn conversations. - </ResponseField> - - <ResponseField name="system_prompt" type="str | None" default="None"> - Instructions that establish the LLM's role and behavior. - </ResponseField> - - <ResponseField name="temperature" type="float | None" default="None"> - Controls randomness (0.0 = deterministic, 1.0 = creative). - </ResponseField> - - <ResponseField name="max_tokens" type="int | None" default="512"> - Maximum tokens to generate. - </ResponseField> - - <ResponseField name="model_preferences" type="str | list[str] | None" default="None"> - Hints for which model the client should use. - </ResponseField> - - <ResponseField name="tools" type="list[Callable] | None" default="None"> - Functions the LLM can call during sampling. - </ResponseField> - - <ResponseField name="result_type" type="type[T] | None" default="None"> - A type for validated structured output. Supports Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. - </ResponseField> - - <ResponseField name="mask_error_details" type="bool | None" default="None"> - If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM. - </ResponseField> - - <ResponseField name="tool_concurrency" type="int | None" default="None"> - Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless. - </ResponseField> - - </Expandable> - - <Expandable title="Response"> - <ResponseField name="SamplingResult[T]" type="dataclass"> - - `.text`: The raw text response (or JSON for structured output) - - `.result`: The typed result—same as `.text` for plain text, or a validated Pydantic object for structured output - - `.history`: All messages exchanged during sampling - </ResponseField> - </Expandable> -</ResponseField> -</Card> - -<Card icon="code" title="ctx.sample_step()"> -<ResponseField name="ctx.sample_step" type="async method"> - Make a single LLM sampling call. Use this for fine-grained control over the sampling loop. - - <Expandable title="Parameters"> - <ResponseField name="messages" type="str | list[str | SamplingMessage]"> - The prompt or conversation history. - </ResponseField> - - <ResponseField name="system_prompt" type="str | None" default="None"> - Instructions that establish the LLM's role and behavior. - </ResponseField> - - <ResponseField name="temperature" type="float | None" default="None"> - Controls randomness (0.0 = deterministic, 1.0 = creative). - </ResponseField> - - <ResponseField name="max_tokens" type="int | None" default="512"> - Maximum tokens to generate. - </ResponseField> - - <ResponseField name="tools" type="list[Callable] | None" default="None"> - Functions the LLM can call during sampling. - </ResponseField> - - <ResponseField name="tool_choice" type="str | None" default="None"> - Controls tool usage: `"auto"`, `"required"`, or `"none"`. - </ResponseField> - - <ResponseField name="execute_tools" type="bool" default="True"> - If True, execute tool calls and append results to history. If False, return immediately with tool calls available for manual execution. - </ResponseField> - - <ResponseField name="mask_error_details" type="bool | None" default="None"> - If True, mask detailed error messages from tool execution. - </ResponseField> - - <ResponseField name="tool_concurrency" type="int | None" default="None"> - Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. - </ResponseField> - </Expandable> - - <Expandable title="Response"> - <ResponseField name="SampleStep" type="dataclass"> - - `.response`: The raw LLM response - - `.history`: Messages including input, assistant response, and tool results - - `.is_tool_use`: True if the LLM requested tool execution - - `.tool_calls`: List of tool calls (if any) - - `.text`: The text content (if any) - </ResponseField> - </Expandable> -</ResponseField> -</Card> diff --git a/docs/v3/servers/server.mdx b/docs/v3/servers/server.mdx deleted file mode 100644 index 65befc502..000000000 --- a/docs/v3/servers/server.mdx +++ /dev/null @@ -1,285 +0,0 @@ ---- -title: The FastMCP Server -sidebarTitle: Overview -description: The core FastMCP server class for building MCP applications -icon: server ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -The `FastMCP` class is the central piece of every FastMCP application. It acts as the container for your tools, resources, and prompts, managing communication with MCP clients and orchestrating the entire server lifecycle. - -## Creating a Server - -At its simplest, a FastMCP server just needs a name. Everything else has sensible defaults. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") -``` - -Instructions help clients (and the LLMs behind them) understand what your server does and how to use it effectively. - -```python -mcp = FastMCP( - "DataAnalysis", - instructions="Provides tools for analyzing numerical datasets. Start with get_summary() for an overview.", -) -``` - -## Components - -FastMCP servers expose three types of components to clients, each serving a distinct role in the MCP protocol. - -**Tools** are functions that clients invoke to perform actions or access external systems. - -```python -@mcp.tool -def multiply(a: float, b: float) -> float: - """Multiplies two numbers together.""" - return a * b -``` - -**Resources** expose data that clients can read — passive data sources rather than invocable functions. - -```python -@mcp.resource("data://config") -def get_config() -> dict: - return {"theme": "dark", "version": "1.0"} -``` - -**Prompts** are reusable message templates that guide LLM interactions. - -```python -@mcp.prompt -def analyze_data(data_points: list[float]) -> str: - formatted_data = ", ".join(str(point) for point in data_points) - return f"Please analyze these data points: {formatted_data}" -``` - -Each component type has detailed documentation: [Tools](/servers/tools), [Resources](/servers/resources) (including [Resource Templates](/servers/resources#resource-templates)), and [Prompts](/servers/prompts). - -## Running the Server - -Start your server by calling `mcp.run()`. The `if __name__` guard ensures compatibility with MCP clients that launch your server as a subprocess. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -@mcp.tool -def greet(name: str) -> str: - """Greet a user by name.""" - return f"Hello, {name}!" - -if __name__ == "__main__": - mcp.run() -``` - -FastMCP supports several transports: -- **STDIO** (default): For local integrations and CLI tools -- **HTTP**: For web services using the Streamable HTTP protocol -- **SSE**: Legacy web transport (deprecated) - -```python -# Run with HTTP transport -mcp.run(transport="http", host="127.0.0.1", port=9000) -``` - -The server can also be run using the FastMCP CLI. For detailed information on transports and deployment, see [Running Your Server](/deployment/running-server). - - -## Configuration Reference - -The `FastMCP` constructor accepts parameters organized into four categories: identity, composition, behavior, and handlers. - -### Identity - -These parameters control how your server presents itself to clients. - -<Card> -<ParamField body="name" type="str" default="FastMCP"> - A human-readable name for your server, shown in client applications and logs -</ParamField> - -<ParamField body="instructions" type="str | None"> - Description of how to interact with this server. Clients surface these instructions to help LLMs understand the server's purpose and available functionality -</ParamField> - -<ParamField body="version" type="str | None"> - Version string for your server. Defaults to the FastMCP library version if not provided -</ParamField> - -<ParamField body="website_url" type="str | None"> - <VersionBadge version="2.13.0" /> - - URL to a website with more information about your server. Displayed in client applications -</ParamField> - -<ParamField body="icons" type="list[Icon] | None"> - <VersionBadge version="2.13.0" /> - - List of icon representations for your server. See [Icons](/servers/icons) for details -</ParamField> - -<ParamField body="experimental_capabilities" type="dict[str, dict[str, Any]] | None"> - <VersionBadge version="3.2.5" /> - - Arbitrary experimental capabilities to advertise in the MCP `initialize` response. Use this to declare cross-server interop conventions or draft extensions that follow the MCP spec's `experimental` field. Keys are capability names; values are free-form dicts. FastMCP's built-in derived capabilities (`tools`, `resources`, etc.) are unaffected — this only populates `capabilities.experimental` -</ParamField> -</Card> - -### Composition - -These parameters control what your server is built from — its components, middleware, providers, and lifecycle. - -<Card> -<ParamField body="tools" type="list[Tool | Callable] | None"> - Tools to register on the server. An alternative to the `@mcp.tool` decorator when you need to add tools programmatically -</ParamField> - -<ParamField body="auth" type="OAuthProvider | TokenVerifier | None"> - Authentication provider for securing HTTP-based transports. See [Authentication](/servers/auth/authentication) for configuration -</ParamField> - -<ParamField body="middleware" type="list[Middleware] | None"> - [Middleware](/servers/middleware) that intercepts and transforms every MCP message flowing through the server — requests, responses, and notifications in both directions. Use for cross-cutting concerns like logging, error handling, and rate limiting -</ParamField> - -<ParamField body="providers" type="list[Provider] | None"> - [Providers](/servers/providers/overview) that supply tools, resources, and prompts dynamically. Providers are queried at request time, so they can serve components from databases, APIs, or other external sources -</ParamField> - -<ParamField body="transforms" type="list[Transform] | None"> - <VersionBadge version="3.1.0" /> - - Server-level [transforms](/servers/transforms/transforms) to apply to all components. Transforms modify how tools, resources, and prompts are presented to clients — for example, [search transforms](/servers/transforms/tool-search) replace large catalogs with on-demand discovery -</ParamField> - -<ParamField body="lifespan" type="Lifespan | AsyncContextManager | None"> - Server-level setup and teardown logic that runs when the server starts and stops. See [Lifespans](/servers/lifespan) for composable lifespans -</ParamField> -</Card> - -### Behavior - -These parameters tune how the server processes requests and communicates with clients. - -<Card> -<ParamField body="on_duplicate" type='Literal["warn", "error", "replace", "ignore"]' default="warn"> - How to handle duplicate component registrations -</ParamField> - -<ParamField body="strict_input_validation" type="bool" default="False"> - <VersionBadge version="2.13.0" /> - - When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` → `10` for int parameters). When `True`, validates inputs against the exact JSON Schema before calling your function, rejecting type mismatches. See [Input Validation Modes](/servers/tools#input-validation-modes) for details -</ParamField> - -<ParamField body="mask_error_details" type="bool | None"> - When `True`, replaces internal error details in tool/resource responses with a generic message to avoid leaking implementation details to clients. Defaults to the `FASTMCP_MASK_ERROR_DETAILS` environment variable -</ParamField> - -<ParamField body="list_page_size" type="int | None" default="None"> - <VersionBadge version="3.0.0" /> - - Maximum items per page for list operations (`tools/list`, `resources/list`, etc.). When `None`, all results are returned in a single response. See [Pagination](/servers/pagination) for details -</ParamField> - -<ParamField body="tasks" type="bool | None" default="False"> - Enable background task support. When `True`, tools and resources can return `CreateTaskResult` to run work asynchronously while the client polls for results -</ParamField> - -<ParamField body="client_log_level" type="LoggingLevel | None"> - <VersionBadge version="3.2.0" /> - - Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"` -</ParamField> - -<ParamField body="dereference_schemas" type="bool" default="True"> - Automatically dereference `$ref` pointers in JSON schemas generated from complex Pydantic models. Most clients require flat schemas without `$ref`, so this should usually stay enabled -</ParamField> -</Card> - -### Handlers and Storage - -These parameters provide custom handlers for MCP capabilities and persistent storage for session state. - -<Card> -<ParamField body="sampling_handler" type="SamplingHandler | None"> - Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details -</ParamField> - -<ParamField body="sampling_handler_behavior" type='Literal["always", "fallback"] | None' default="fallback"> - When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests -</ParamField> - -<ParamField body="session_state_store" type="AsyncKeyValue | None"> - Persistent key-value store for session state that survives across requests. Defaults to an in-memory store. Provide a custom implementation for persistence across server restarts -</ParamField> -</Card> - - -## Tag-Based Filtering - -<VersionBadge version="2.8.0" /> - -Tags let you categorize components and selectively expose them. This is useful for creating different views of your server for different environments or user types. - -```python -@mcp.tool(tags={"public", "utility"}) -def public_tool() -> str: - return "This tool is public" - -@mcp.tool(tags={"internal", "admin"}) -def admin_tool() -> str: - return "This tool is for admins only" -``` - -The filtering logic works as follows: -- **Enable with `only=True`**: Switches to allowlist mode — only components with at least one matching tag are exposed -- **Disable**: Components with any matching tag are hidden -- **Precedence**: Later calls override earlier ones, so call `disable` after `enable` to exclude from an allowlist - -<Tip> -To ensure a component is never exposed, you can set `enabled=False` on the component itself. See the component-specific documentation for details. -</Tip> - -```python -# Only expose components tagged with "public" -mcp = FastMCP() -mcp.enable(tags={"public"}, only=True) - -# Hide components tagged as "internal" or "deprecated" -mcp = FastMCP() -mcp.disable(tags={"internal", "deprecated"}) - -# Combine both: show admin tools but hide deprecated ones -mcp = FastMCP() -mcp.enable(tags={"admin"}, only=True).disable(tags={"deprecated"}) -``` - -This filtering applies to all component types (tools, resources, resource templates, and prompts) and affects both listing and access. - -## Custom Routes - -When running with HTTP transport, you can add custom web routes alongside your MCP endpoint using the `@custom_route` decorator. - -```python -from fastmcp import FastMCP -from starlette.requests import Request -from starlette.responses import PlainTextResponse - -mcp = FastMCP("MyServer") - -@mcp.custom_route("/health", methods=["GET"]) -async def health_check(request: Request) -> PlainTextResponse: - return PlainTextResponse("OK") - -if __name__ == "__main__": - mcp.run(transport="http") # Health check at http://localhost:8000/health -``` - -Custom routes are useful for health checks, status endpoints, and simple webhooks. For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). diff --git a/docs/v3/servers/storage-backends.mdx b/docs/v3/servers/storage-backends.mdx deleted file mode 100644 index d13ce176d..000000000 --- a/docs/v3/servers/storage-backends.mdx +++ /dev/null @@ -1,296 +0,0 @@ ---- -title: Storage Backends -sidebarTitle: Storage Backends -description: Configure persistent and distributed storage for caching and OAuth state management -icon: database -tag: NEW ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.13.0" /> - -FastMCP uses pluggable storage backends for caching responses and managing OAuth state. By default, all storage is in-memory, which is perfect for development but doesn't persist across restarts. FastMCP includes support for multiple storage backends, and you can easily extend it with custom implementations. - -<Tip> -The storage layer is powered by **[py-key-value-aio](https://github.com/strawgate/py-key-value)**, an async key-value library maintained by a core FastMCP maintainer. This library provides a unified interface for multiple backends, making it easy to swap implementations based on your deployment needs. -</Tip> - -## Available Backends - -### In-Memory Storage - -**Best for:** Development, testing, single-process deployments - -In-memory storage is the default for all FastMCP storage needs. It's fast, requires no setup, and is perfect for getting started. - -```python -from key_value.aio.stores.memory import MemoryStore - -# Used by default - no configuration needed -# But you can also be explicit: -cache_store = MemoryStore() -``` - -**Characteristics:** -- ✅ No setup required -- ✅ Very fast -- ❌ Data lost on restart -- ❌ Not suitable for multi-process deployments - -### File Storage - -**Best for:** Single-server production deployments, persistent caching - -File storage persists data to the filesystem as one JSON file per key, allowing it to survive server restarts. This is the default backend for OAuth storage on Mac and Windows. - -```python -from pathlib import Path -from key_value.aio.stores.filetree import ( - FileTreeStore, - FileTreeV1KeySanitizationStrategy, - FileTreeV1CollectionSanitizationStrategy, -) -from fastmcp.server.middleware.caching import ResponseCachingMiddleware - -storage_dir = Path("/var/cache/fastmcp") -store = FileTreeStore( - data_directory=storage_dir, - key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(storage_dir), - collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(storage_dir), -) - -# Persistent response cache -middleware = ResponseCachingMiddleware(cache_storage=store) -``` - -<Warning> -**Sanitization strategies are required** when using `FileTreeStore`. Without them, keys containing special characters (such as URL-based OAuth client IDs like `https://claude.ai/oauth/claude-code-client-metadata`) will be used as-is in filesystem paths, causing `FileNotFoundError` crashes. The V1 strategies shown above are safe defaults — alphanumeric names pass through as-is for readability, while special characters are hashed to prevent path errors and traversal attacks. Changing sanitization strategies after data has been written is a breaking change, so choose your strategy upfront. -</Warning> - -**Characteristics:** -- ✅ Data persists across restarts -- ✅ No external dependencies -- ✅ Human-readable files on disk -- ❌ Not suitable for distributed deployments -- ❌ Filesystem access required - -### Redis - -**Best for:** Distributed production deployments, shared caching across multiple servers - -<Note> -Redis support requires an optional dependency: `pip install 'py-key-value-aio[redis]'` -</Note> - -Redis provides distributed caching and state management, ideal for production deployments with multiple server instances. - -```python -from key_value.aio.stores.redis import RedisStore -from fastmcp.server.middleware.caching import ResponseCachingMiddleware - -# Distributed response cache -middleware = ResponseCachingMiddleware( - cache_storage=RedisStore(host="redis.example.com", port=6379) -) -``` - -With authentication: - -```python -from key_value.aio.stores.redis import RedisStore - -cache_store = RedisStore( - host="redis.example.com", - port=6379, - password="your-redis-password" -) -``` - -For OAuth token storage: - -```python -import os -from fastmcp.server.auth.providers.github import GitHubProvider -from key_value.aio.stores.redis import RedisStore - -auth = GitHubProvider( - client_id=os.environ["GITHUB_CLIENT_ID"], - client_secret=os.environ["GITHUB_CLIENT_SECRET"], - base_url="https://your-server.com", - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - client_storage=RedisStore(host="redis.example.com", port=6379) -) -``` - -**Characteristics:** -- ✅ Distributed and highly available -- ✅ Fast in-memory performance -- ✅ Works across multiple server instances -- ✅ Built-in TTL support -- ❌ Requires Redis infrastructure -- ❌ Network latency vs local storage - -### Other Backends from py-key-value-aio - -The py-key-value-aio library includes additional implementations for various storage systems: - -- **DynamoDB** - AWS distributed database -- **MongoDB** - NoSQL document store -- **Elasticsearch** - Distributed search and analytics -- **Memcached** - Distributed memory caching -- **RocksDB** - Embedded high-performance key-value store -- **Valkey** - Redis-compatible server - -For configuration details on these backends, consult the [py-key-value-aio documentation](https://github.com/strawgate/py-key-value). - -<Warning> -Before using these backends in production, review the [py-key-value documentation](https://github.com/strawgate/py-key-value) to understand the maturity level and limitations of your chosen backend. Some backends may be in preview or have specific constraints that make them unsuitable for production use. -</Warning> - -## Use Cases in FastMCP - -### Server-Side OAuth Token Storage - -The [OAuth Proxy](/servers/auth/oauth-proxy) and OAuth auth providers use storage for persisting OAuth client registrations and upstream tokens. **By default, storage is automatically encrypted using `FernetEncryptionWrapper`.** When providing custom storage, wrap it in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest. - -**Development (default behavior):** - -By default, FastMCP automatically manages keys and storage based on your platform: -- **Mac/Windows**: Keys are auto-managed via system keyring, storage defaults to disk. Suitable **only** for development and local testing. -- **Linux**: Keys are ephemeral, storage defaults to memory. - -No configuration needed: - -```python -from fastmcp.server.auth.providers.github import GitHubProvider - -auth = GitHubProvider( - client_id="your-id", - client_secret="your-secret", - base_url="https://your-server.com" -) -``` - -**Production:** - -For production deployments, configure explicit keys and persistent network-accessible storage with encryption: - -```python -import os -from fastmcp.server.auth.providers.github import GitHubProvider -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from cryptography.fernet import Fernet - -auth = GitHubProvider( - client_id=os.environ["GITHUB_CLIENT_ID"], - client_secret=os.environ["GITHUB_CLIENT_SECRET"], - base_url="https://your-server.com", - # Explicit JWT signing key (required for production) - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], - # Encrypted persistent storage (required for production) - client_storage=FernetEncryptionWrapper( - key_value=RedisStore(host="redis.example.com", port=6379), - fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) - ) -) -``` - -Both parameters are required for production. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. See [OAuth Token Security](/deployment/http#oauth-token-security) and [Key and Storage Management](/servers/auth/oauth-proxy#key-and-storage-management) for complete setup details. - -### Response Caching Middleware - -The [Response Caching Middleware](/servers/middleware#caching-middleware) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter: - -```python -from pathlib import Path -from fastmcp import FastMCP -from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from key_value.aio.stores.filetree import ( - FileTreeStore, - FileTreeV1KeySanitizationStrategy, - FileTreeV1CollectionSanitizationStrategy, -) - -mcp = FastMCP("My Server") - -cache_dir = Path("cache") -cache_store = FileTreeStore( - data_directory=cache_dir, - key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(cache_dir), - collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(cache_dir), -) - -# Cache to disk instead of memory -mcp.add_middleware(ResponseCachingMiddleware(cache_storage=cache_store)) -``` - -For multi-server deployments sharing a Redis instance: - -```python -from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from key_value.aio.stores.redis import RedisStore -from key_value.aio.wrappers.prefix_collections import PrefixCollectionsWrapper - -base_store = RedisStore(host="redis.example.com") -namespaced_store = PrefixCollectionsWrapper( - key_value=base_store, - prefix="my-server" -) - -middleware = ResponseCachingMiddleware(cache_storage=namespaced_store) -``` - -### Client-Side OAuth Token Storage - -The [FastMCP Client](/clients/client) uses storage for persisting OAuth tokens locally. By default, tokens are stored in memory: - -```python -from pathlib import Path -from fastmcp.client.auth import OAuth -from key_value.aio.stores.filetree import ( - FileTreeStore, - FileTreeV1KeySanitizationStrategy, - FileTreeV1CollectionSanitizationStrategy, -) - -# Store tokens on disk for persistence across restarts -token_dir = Path("~/.local/share/fastmcp/tokens").expanduser() -token_storage = FileTreeStore( - data_directory=token_dir, - key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(token_dir), - collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(token_dir), -) - -oauth_provider = OAuth( - mcp_url="https://your-mcp-server.com/mcp/sse", - token_storage=token_storage -) -``` - -This allows clients to reconnect without re-authenticating after restarts. - -## Choosing a Backend - -| Backend | Development | Single Server | Multi-Server | Cloud Native | -|---------|-------------|---------------|--------------|--------------| -| Memory | ✅ Best | ⚠️ Limited | ❌ | ❌ | -| File | ✅ Good | ✅ Recommended | ❌ | ⚠️ | -| Redis | ⚠️ Overkill | ✅ Good | ✅ Best | ✅ Best | -| DynamoDB | ❌ | ⚠️ | ✅ | ✅ Best (AWS) | -| MongoDB | ❌ | ⚠️ | ✅ | ✅ Good | - -**Decision tree:** - -1. **Just starting?** Use **Memory** (default) - no configuration needed -2. **Single server, needs persistence?** Use **File** -3. **Multiple servers or cloud deployment?** Use **Redis** or **DynamoDB** -4. **Existing infrastructure?** Look for a matching py-key-value-aio backend - -## More Resources - -- [py-key-value-aio GitHub](https://github.com/strawgate/py-key-value) - Full library documentation -- [Response Caching Middleware](/servers/middleware#caching-middleware) - Using storage for caching -- [OAuth Token Security](/deployment/http#oauth-token-security) - Production OAuth configuration -- [HTTP Deployment](/deployment/http) - Complete deployment guide diff --git a/docs/v3/servers/tasks.mdx b/docs/v3/servers/tasks.mdx deleted file mode 100644 index d4aae9f54..000000000 --- a/docs/v3/servers/tasks.mdx +++ /dev/null @@ -1,263 +0,0 @@ ---- -title: Background Tasks -sidebarTitle: Background Tasks -description: Run long-running operations asynchronously with progress tracking -icon: clock -tag: "NEW" ---- - -import { VersionBadge } from "/snippets/version-badge.mdx" - -<VersionBadge version="2.14.0" /> - -<Tip> -Background tasks require the `tasks` optional extra. See [installation instructions](#enabling-background-tasks) below. -</Tip> - -FastMCP implements the MCP background task protocol ([SEP-1686](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)), giving your servers a production-ready distributed task scheduler with a single decorator change. - -<Tip> -**What is Docket?** FastMCP's task system is powered by [Docket](https://github.com/chrisguidry/docket), originally built by [Prefect](https://prefect.io) to power [Prefect Cloud](https://www.prefect.io/prefect/cloud)'s managed task scheduling and execution service, where it processes millions of concurrent tasks every day. Docket is now open-sourced for the community. -</Tip> - - -## What Are MCP Background Tasks? - -In MCP, all component interactions are blocking by default. When a client calls a tool, reads a resource, or fetches a prompt, it sends a request and waits for the response. For operations that take seconds or minutes, this creates a poor user experience. - -The MCP background task protocol solves this by letting clients: -1. **Start** an operation and receive a task ID immediately -2. **Track** progress as the operation runs -3. **Retrieve** the result when ready - -FastMCP handles all of this for you. Add `task=True` to your decorator, and your function gains full background execution with progress reporting, distributed processing, and horizontal scaling. - -### MCP Background Tasks vs Python Concurrency - -You can always use Python's concurrency primitives (asyncio, threads, multiprocessing) or external task queues in your FastMCP servers. FastMCP is just Python—run code however you like. - -MCP background tasks are different: they're **protocol-native**. This means MCP clients that support the task protocol can start operations, receive progress updates, and retrieve results through the standard MCP interface. The coordination happens at the protocol level, not inside your application code. - -## Enabling Background Tasks - -<VersionBadge version="3.0.0" /> Background tasks require the `tasks` extra: - -```bash -pip install "fastmcp[tasks]" -``` - -Add `task=True` to any tool, resource, resource template, or prompt decorator. This marks the component as capable of background execution. - -```python {6} -import asyncio -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -@mcp.tool(task=True) -async def slow_computation(duration: int) -> str: - """A long-running operation.""" - for i in range(duration): - await asyncio.sleep(1) - return f"Completed in {duration} seconds" -``` - -When a client requests background execution, the call returns immediately with a task ID. The work executes in a background worker, and the client can poll for status or wait for the result. - -<Warning> -Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time. -</Warning> - -## Execution Modes - -For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The MCP task protocol defines three execution modes: - -| Mode | Client calls without task | Client calls with task | -|------|--------------------------|------------------------| -| `"forbidden"` | Executes synchronously | Error: task not supported | -| `"optional"` | Executes synchronously | Executes as background task | -| `"required"` | Error: task required | Executes as background task | - -```python -from fastmcp import FastMCP -from fastmcp.server.tasks import TaskConfig - -mcp = FastMCP("MyServer") - -# Supports both sync and background execution (default when task=True) -@mcp.tool(task=TaskConfig(mode="optional")) -async def flexible_task() -> str: - return "Works either way" - -# Requires background execution - errors if client doesn't request task -@mcp.tool(task=TaskConfig(mode="required")) -async def must_be_background() -> str: - return "Only runs as a background task" - -# No task support (default when task=False or omitted) -@mcp.tool(task=TaskConfig(mode="forbidden")) -async def sync_only() -> str: - return "Never runs as background task" -``` - -The boolean shortcuts map to these modes: -- `task=True` → `TaskConfig(mode="optional")` -- `task=False` → `TaskConfig(mode="forbidden")` - -### Poll Interval - -<VersionBadge version="2.15.0" /> - -When clients poll for task status, the server tells them how frequently to check back. By default, FastMCP suggests a 5-second interval, but you can customize this per component: - -```python -from datetime import timedelta -from fastmcp import FastMCP -from fastmcp.server.tasks import TaskConfig - -mcp = FastMCP("MyServer") - -# Poll every 2 seconds for a fast-completing task -@mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=2))) -async def quick_task() -> str: - return "Done quickly" - -# Poll every 30 seconds for a long-running task -@mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=30))) -async def slow_task() -> str: - return "Eventually done" -``` - -Shorter intervals give clients faster feedback but increase server load. Longer intervals reduce load but delay status updates. - -### Server-Wide Default - -To enable background task support for all components by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`. - -```python -mcp = FastMCP("MyServer", tasks=True) -``` - -<Warning> -If your server defines any synchronous tools, resources, or prompts, you will need to explicitly set `task=False` on their decorators to avoid an error. -</Warning> - -### Graceful Degradation - -When a client requests background execution but the component has `mode="forbidden"`, FastMCP executes synchronously and returns the result inline. This follows the SEP-1686 specification for graceful degradation—clients can always request background execution without worrying about server capabilities. - -Conversely, when a component has `mode="required"` but the client doesn't request background execution, FastMCP returns an error indicating that task execution is required. - -### Configuration - -| Environment Variable | Default | Description | -|---------------------|---------|-------------| -| `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) | - -## Backends - -FastMCP supports two backends for task execution, each with different tradeoffs. - -### In-Memory Backend (Default) - -The in-memory backend (`memory://`) requires zero configuration and works out of the box. - -**Advantages:** -- No external dependencies -- Simple single-process deployment - -**Disadvantages:** -- **Ephemeral**: If the server restarts, all pending tasks are lost -- **Higher latency**: ~250ms task pickup time vs single-digit milliseconds with Redis -- **No horizontal scaling**: Single process only—you cannot add additional workers - -### Redis Backend - -For production deployments, use Redis (or Valkey) as your backend by setting `FASTMCP_DOCKET_URL=redis://localhost:6379`. - -**Advantages:** -- **Persistent**: Tasks survive server restarts -- **Fast**: Single-digit millisecond task pickup latency -- **Scalable**: Add workers to distribute load across processes or machines - -## Workers - -Every FastMCP server with task-enabled components automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute. - -To scale horizontally, add more workers using the CLI: - -```bash -fastmcp tasks worker server.py -``` - -Each additional worker pulls tasks from the same queue, distributing load across processes. Configure worker concurrency via environment: - -```bash -export FASTMCP_DOCKET_CONCURRENCY=20 -fastmcp tasks worker server.py -``` - -<Note> -Additional workers only work with Redis/Valkey backends. The in-memory backend is single-process only. -</Note> - -<Warning> -Task-enabled components must be defined at server startup to be registered with all workers. Components added dynamically after the server starts will not be available for background execution. -</Warning> - -## Progress Reporting - -The `Progress` dependency lets you report progress back to clients. Inject it as a parameter with a default value, and FastMCP will provide the active progress reporter. - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import Progress - -mcp = FastMCP("MyServer") - -@mcp.tool(task=True) -async def process_files(files: list[str], progress: Progress = Progress()) -> str: - await progress.set_total(len(files)) - - for file in files: - await progress.set_message(f"Processing {file}") - # ... do work ... - await progress.increment() - - return f"Processed {len(files)} files" -``` - -The progress API: -- `await progress.set_total(n)` — Set the total number of steps -- `await progress.increment(amount=1)` — Increment progress -- `await progress.set_message(text)` — Update the status message - -Progress works in both immediate and background execution modes—you can use the same code regardless of how the client invokes your function. - -## Docket Dependencies - -FastMCP exposes Docket's full dependency injection system within your task-enabled functions. Beyond `Progress`, you can access the Docket instance, worker information, and use advanced features like retries and timeouts. - -```python -from docket import Docket, Worker -from fastmcp import FastMCP -from fastmcp.dependencies import Progress, CurrentDocket, CurrentWorker - -mcp = FastMCP("MyServer") - -@mcp.tool(task=True) -async def my_task( - progress: Progress = Progress(), - docket: Docket = CurrentDocket(), - worker: Worker = CurrentWorker(), -) -> str: - # Schedule additional background work - await docket.add(another_task, arg1, arg2) - - # Access worker metadata - worker_name = worker.name - - return "Done" -``` - -With `CurrentDocket()`, you can schedule additional background tasks, chain work together, and coordinate complex workflows. See the [Docket documentation](https://chrisguidry.github.io/docket/) for the complete API, including retry policies, timeouts, and custom dependencies. diff --git a/docs/v3/servers/telemetry.mdx b/docs/v3/servers/telemetry.mdx deleted file mode 100644 index aed7308db..000000000 --- a/docs/v3/servers/telemetry.mdx +++ /dev/null @@ -1,345 +0,0 @@ ---- -title: OpenTelemetry -sidebarTitle: Telemetry -description: Native OpenTelemetry instrumentation for distributed tracing. -icon: chart-line -tag: NEW ---- - -FastMCP includes native OpenTelemetry instrumentation for observability. Traces are automatically generated for tool, prompt, resource, and resource template operations, providing visibility into server behavior, request handling, and provider delegation chains. - -## How It Works - -FastMCP uses the OpenTelemetry API for instrumentation. This means: - -- **Zero configuration required** - Instrumentation is always active -- **No overhead when unused** - Without an SDK, all operations are no-ops -- **Bring your own SDK** - You control collection, export, and sampling -- **Works with any OTEL backend** - Jaeger, Zipkin, Datadog, New Relic, etc. - -## Enabling Telemetry - -The easiest way to export traces is using `opentelemetry-instrument`, which configures the SDK automatically: - -```bash -pip install opentelemetry-distro opentelemetry-exporter-otlp -opentelemetry-bootstrap -a install -``` - -Then run your server with tracing enabled: - -```bash -opentelemetry-instrument \ - --service_name my-fastmcp-server \ - --exporter_otlp_endpoint http://localhost:4317 \ - fastmcp run server.py -``` - -Or configure via environment variables: - -```bash -export OTEL_SERVICE_NAME=my-fastmcp-server -export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 - -opentelemetry-instrument fastmcp run server.py -``` - -This works with any OTLP-compatible backend (Jaeger, Zipkin, Grafana Tempo, Datadog, etc.) and requires no changes to your FastMCP code. - -<Card title="OpenTelemetry Python Documentation" icon="book" href="https://opentelemetry.io/docs/languages/python/"> - Learn more about the OpenTelemetry Python SDK, auto-instrumentation, and available exporters. -</Card> - -## Tracing - -FastMCP creates spans for all MCP operations, providing end-to-end visibility into request handling. - -### Server Spans - -The server creates spans for each operation using [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/): - -| Span Name | Description | -|-----------|-------------| -| `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) | -| `resources/read` | Resource read (URI in `mcp.resource.uri` attribute, not span name) | -| `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) | - -For mounted servers, an additional `delegate {name}` span shows the delegation to the child server. - -### Client Spans - -The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read`, `prompts/get {name}`). - -### Span Hierarchy - -Spans form a hierarchy showing the request flow. For mounted servers: - -``` -tools/call weather_forecast (CLIENT) - └── tools/call weather_forecast (SERVER, provider=FastMCPProvider) - └── delegate get_weather (INTERNAL) - └── tools/call get_weather (SERVER, provider=LocalProvider) -``` - -For proxy providers connecting to remote servers: - -``` -tools/call remote_search (CLIENT) - └── tools/call remote_search (SERVER, provider=ProxyProvider) - └── [remote server spans via trace context propagation] -``` - -## Programmatic Configuration - -For more control, configure the SDK in your Python code before importing FastMCP: - -```python -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - -# Configure the SDK with OTLP exporter -provider = TracerProvider() -processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317")) -provider.add_span_processor(processor) -trace.set_tracer_provider(provider) - -# Now import and use FastMCP - traces will be exported automatically -from fastmcp import FastMCP - -mcp = FastMCP("my-server") - -@mcp.tool() -def greet(name: str) -> str: - return f"Hello, {name}!" -``` - -<Tip> -The SDK must be configured **before** importing FastMCP to ensure the tracer provider is set when FastMCP initializes. -</Tip> - -### Local Development - -For quick local trace visualization, [otel-desktop-viewer](https://github.com/CtrlSpice/otel-desktop-viewer) is a lightweight single-binary tool: - -```bash -# macOS -brew install nico-barbas/brew/otel-desktop-viewer - -# Or download from GitHub releases -``` - -Run it alongside your server: - -```bash -# Terminal 1: Start the viewer (UI at http://localhost:8000, OTLP on :4317) -otel-desktop-viewer - -# Terminal 2: Run your server with tracing -opentelemetry-instrument fastmcp run server.py -``` - -For more features, use [Jaeger](https://www.jaegertracing.io/): - -```bash -docker run -d --name jaeger \ - -p 16686:16686 \ - -p 4317:4317 \ - jaegertracing/all-in-one:latest -``` - -Then view traces at http://localhost:16686 - -## Custom Spans - -You can add your own spans using the FastMCP tracer: - -```python -from fastmcp import FastMCP -from fastmcp.telemetry import get_tracer - -mcp = FastMCP("custom-spans") - -@mcp.tool() -async def complex_operation(input: str) -> str: - tracer = get_tracer() - - with tracer.start_as_current_span("parse_input") as span: - span.set_attribute("input.length", len(input)) - parsed = parse(input) - - with tracer.start_as_current_span("process_data") as span: - span.set_attribute("data.count", len(parsed)) - result = process(parsed) - - return result -``` - -### Where custom spans help most - -Custom spans are most useful around work that is expensive or hard to debug: - -- External calls such as databases, vector stores, HTTP APIs, or queue operations -- Multi-step tool logic where one stage dominates latency -- Prompt or resource generation that fans out to other systems -- Sampling calls made from inside a tool via `ctx.sample(...)` - -Avoid wrapping every small helper function or simple in-memory transformation. That usually adds noise without making traces easier to interpret. - -### Recommended naming and attributes - -- Use `{tool_name}.{operation}` or `{resource_name}.{operation}` for child spans such as `search.fetch`, `search.rank`, or `docs.render` -- Add attributes that explain workload shape, such as counts, sizes, cache hits, or IDs -- Do not record secrets, prompts with sensitive user data, or raw tokens as span attributes -- Let exceptions propagate unless you have a specific recovery path; FastMCP's server spans already mark failures and record exceptions - -### Instrumenting tools, prompts, and resources - -```python -from fastmcp import FastMCP -from fastmcp.telemetry import get_tracer - -mcp = FastMCP("my-server") - -@mcp.tool -async def search(query: str) -> str: - tracer = get_tracer() - - with tracer.start_as_current_span("search.fetch") as span: - span.set_attribute("search.query_length", len(query)) - results = await fetch_results(query) - span.set_attribute("search.result_count", len(results)) - - with tracer.start_as_current_span("search.rank"): - ranked = rank_results(results) - - return format_results(ranked) - -@mcp.prompt -async def summarize_prompt(topic: str) -> str: - tracer = get_tracer() - with tracer.start_as_current_span("summarize_prompt.render") as span: - span.set_attribute("prompt.topic_length", len(topic)) - return f"Summarize the latest updates about {topic}." - -@mcp.resource("docs://{slug}") -async def docs_resource(slug: str) -> str: - tracer = get_tracer() - with tracer.start_as_current_span("docs_resource.load") as span: - span.set_attribute("docs.slug", slug) - return await load_doc(slug) -``` - -### Sampling calls inside tools - -If your tool uses `ctx.sample(...)`, keep the LLM work nested under the tool span so traces show both application logic and model latency together. - -For providers with their own OTEL integrations, prefer enabling that instrumentation rather than manually creating a span around every model call. For example, if you use Google GenAI, `logfire.instrument_google_genai()` will emit child spans with token and request metadata under the active FastMCP tool span. - -### Exporter choices - -- For local debugging, `ConsoleSpanExporter` or `otel-desktop-viewer` gives quick feedback with minimal setup -- For shared environments, use OTLP exporters to backends like Logfire, Jaeger, Tempo, Datadog, or New Relic -- If traces are too noisy, tune sampling in your OpenTelemetry SDK instead of removing FastMCP instrumentation - -## Error Handling - -When errors occur, spans are automatically marked with error status and the exception is recorded: - -```python -@mcp.tool() -def risky_operation() -> str: - raise ValueError("Something went wrong") - -# The span will have: -# - status = ERROR with exception message as description -# - error.type = "tool_error" (or exception class name for non-tool errors) -# - exception event with stack trace -``` - -## Attributes Reference - -<Warning> -**Migrating from v3.1 or earlier:** The `rpc.system`, `rpc.service`, and `rpc.method` span attributes were removed in favor of the [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/) listed below. If you have dashboards or alerts keyed on those `rpc.*` attributes, update them to use `mcp.method.name` and the `fastmcp.*` attributes instead. -</Warning> - -### MCP Semantic Conventions - -FastMCP implements the [OpenTelemetry MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/): - -| Attribute | Description | -|-----------|-------------| -| `mcp.method.name` | The MCP method being called (`tools/call`, `resources/read`, `prompts/get`) | -| `mcp.session.id` | Session identifier for the MCP connection | -| `mcp.resource.uri` | The resource URI (for resource operations) | -| `gen_ai.tool.name` | Tool name (on `tools/call` spans) | -| `gen_ai.prompt.name` | Prompt name (on `prompts/get` spans) | -| `error.type` | Error classification (`tool_error` for ToolError, otherwise exception class name) | - -### Auth Attributes - -Standard [identity attributes](https://opentelemetry.io/docs/specs/semconv/attributes-registry/enduser/): - -| Attribute | Description | -|-----------|-------------| -| `enduser.id` | Client ID from access token (when authenticated) | -| `enduser.scope` | Space-separated OAuth scopes (when authenticated) | - -### FastMCP Custom Attributes - -All custom attributes use the `fastmcp.` prefix for features unique to FastMCP: - -| Attribute | Description | -|-----------|-------------| -| `fastmcp.server.name` | Server name | -| `fastmcp.component.type` | `tool`, `resource`, `prompt`, or `resource_template` | -| `fastmcp.component.key` | Full component identifier (e.g., `tool:greet`) | -| `fastmcp.provider.type` | Provider class (`LocalProvider`, `FastMCPProvider`, `ProxyProvider`) | - -Provider-specific attributes for delegation context: - -| Attribute | Description | -|-----------|-------------| -| `fastmcp.delegate.original_name` | Original tool/prompt name before namespacing | -| `fastmcp.delegate.original_uri` | Original resource URI before namespacing | -| `fastmcp.proxy.backend_name` | Remote server tool/prompt name | -| `fastmcp.proxy.backend_uri` | Remote server resource URI | - -## Testing with Telemetry - -For testing, use the in-memory exporter: - -```python -import pytest -from collections.abc import Generator -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter - -from fastmcp import FastMCP - -@pytest.fixture -def trace_exporter() -> Generator[InMemorySpanExporter, None, None]: - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - original_provider = trace.get_tracer_provider() - trace.set_tracer_provider(provider) - yield exporter - exporter.clear() - trace.set_tracer_provider(original_provider) - -async def test_tool_creates_span(trace_exporter: InMemorySpanExporter) -> None: - mcp = FastMCP("test") - - @mcp.tool() - def hello() -> str: - return "world" - - await mcp.call_tool("hello", {}) - - spans = trace_exporter.get_finished_spans() - assert any(s.name == "tools/call hello" for s in spans) -``` diff --git a/docs/v3/servers/testing.mdx b/docs/v3/servers/testing.mdx deleted file mode 100644 index 7bd8600c5..000000000 --- a/docs/v3/servers/testing.mdx +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: Testing your FastMCP Server -sidebarTitle: Testing -description: How to test your FastMCP server. -icon: vial ---- - -The best way to ensure a reliable and maintainable FastMCP Server is to test it! The FastMCP Client combined with Pytest provides a simple and powerful way to test your FastMCP servers. - -## Prerequisites - -Testing FastMCP servers requires `pytest-asyncio` to handle async test functions and fixtures. Install it as a development dependency: - -```bash -pip install pytest-asyncio -``` - -We recommend configuring pytest to automatically handle async tests by setting the asyncio mode to `auto` in your `pyproject.toml`: - -```toml -[tool.pytest.ini_options] -asyncio_mode = "auto" -``` - -This eliminates the need to decorate every async test with `@pytest.mark.asyncio`. - -## Testing with Pytest Fixtures - -Using Pytest Fixtures, you can wrap your FastMCP Server in a Client instance that makes interacting with your server fast and easy. This is especially useful when building your own MCP Servers and enables a tight development loop by allowing you to avoid using a separate tool like MCP Inspector during development: - -```python -import pytest -from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport - -from my_project.main import mcp - -@pytest.fixture -async def main_mcp_client(): - async with Client(transport=mcp) as mcp_client: - yield mcp_client - -async def test_list_tools(main_mcp_client: Client[FastMCPTransport]): - list_tools = await main_mcp_client.list_tools() - - assert len(list_tools) == 5 -``` - -We recommend the [inline-snapshot library](https://github.com/15r10nk/inline-snapshot) for asserting complex data structures coming from your MCP Server. This library allows you to write tests that are easy to read and understand, and are also easy to update when the data structure changes. - -```python -from inline_snapshot import snapshot - -async def test_list_tools(main_mcp_client: Client[FastMCPTransport]): - list_tools = await main_mcp_client.list_tools() - - assert list_tools == snapshot() -``` - -Simply run `pytest --inline-snapshot=fix,create` to fill in the `snapshot()` with actual data. - -<Tip> -For values that change you can leverage the [dirty-equals](https://github.com/samuelcolvin/dirty-equals) library to perform flexible equality assertions on dynamic or non-deterministic values. -</Tip> - -Using the pytest `parametrize` decorator, you can easily test your tools with a wide variety of inputs. - -```python -import pytest -from my_project.main import mcp - -from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport -@pytest.fixture -async def main_mcp_client(): - async with Client(mcp) as client: - yield client - - -@pytest.mark.parametrize( - "first_number, second_number, expected", - [ - (1, 2, 3), - (2, 3, 5), - (3, 4, 7), - ], -) -async def test_add( - first_number: int, - second_number: int, - expected: int, - main_mcp_client: Client[FastMCPTransport], -): - result = await main_mcp_client.call_tool( - name="add", arguments={"x": first_number, "y": second_number} - ) - assert result.data is not None - assert isinstance(result.data, int) - assert result.data == expected -``` - -<Tip> -The [FastMCP Repository contains thousands of tests](https://github.com/PrefectHQ/fastmcp/tree/main/tests) for the FastMCP Client and Server. Everything from connecting to remote MCP servers, to testing tools, resources, and prompts is covered, take a look for inspiration! -</Tip> \ No newline at end of file diff --git a/docs/v3/servers/tool-fingerprinting.mdx b/docs/v3/servers/tool-fingerprinting.mdx deleted file mode 100644 index b8c06c04e..000000000 --- a/docs/v3/servers/tool-fingerprinting.mdx +++ /dev/null @@ -1,156 +0,0 @@ ---- -title: Tool Fingerprinting -sidebarTitle: Tool Fingerprinting -description: Build stable fingerprints for tool identity and schema change detection -icon: fingerprint ---- - -import { VersionBadge } from "/snippets/version-badge.mdx"; - -<VersionBadge version="3.0.0" /> - -Downstream systems like routers, gateways, and audit loggers often need to detect whether a tool's schema changed between deployments. Rather than each system inventing its own JSON normalization and hashing logic, you can build stable fingerprints from FastMCP's existing API surface. - -FastMCP does not define a single "contract hash" because the inclusion policy is necessarily application-specific: some systems care only about the input schema, others include the description, metadata, tags, or version. Instead, this recipe shows how to assemble a fingerprint payload from the parts you care about, then hash it deterministically. - -## The Recipe - -The two key building blocks are: - -- **`tool.key`** — FastMCP's canonical component identity, encoding type, name, and version (e.g. `tool:greet@1.0` or `tool:greet@`) -- **`tool.to_mcp_tool()`** — the protocol-facing tool object that MCP clients see, including the input schema - -Combine them into a payload, serialize deterministically, and hash: - -```python -import hashlib -import json - -from fastmcp import FastMCP - -mcp = FastMCP("demo") - - -@mcp.tool() -def greet(name: str) -> str: - """Say hello.""" - return f"Hello {name}" - - -async def fingerprint_tool(server: FastMCP, tool_name: str) -> str: - tool = await server.get_tool(tool_name) - if tool is None: - raise ValueError(f"Tool {tool_name!r} not found") - - mcp_tool = tool.to_mcp_tool() - dumped = mcp_tool.model_dump(mode="json", by_alias=True, exclude_none=True) - - payload = { - "key": tool.key, - "inputSchema": dumped["inputSchema"], - } - - canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() -``` - -The fingerprint is stable across process restarts as long as the tool's name, version, and input schema remain the same. - -## Why `tool.key`? - -`tool.key` is FastMCP's canonical component identity. It encodes the component type, identifier, and version into a single string: - -``` -tool:greet@1.0 # versioned tool -tool:greet@ # unversioned tool -``` - -Using `key` rather than just the tool name ensures that two versions of the same tool produce distinct fingerprints, and that a tool and a resource with the same name cannot collide. - -## Why `to_mcp_tool()`? - -`to_mcp_tool()` returns the protocol-facing representation — the shape that MCP clients actually receive. This matters because routers and gateways typically operate on the protocol layer, not FastMCP internals. The `model_dump(mode="json", by_alias=True, exclude_none=True)` call produces a clean, serializable dictionary using the MCP protocol field names. - -## Customizing the Payload - -You own the inclusion policy. Add or remove fields depending on what constitutes a "contract" in your system: - -```python -async def custom_fingerprint(server: FastMCP, tool_name: str) -> str: - tool = await server.get_tool(tool_name) - if tool is None: - raise ValueError(f"Tool {tool_name!r} not found") - - mcp_tool = tool.to_mcp_tool() - dumped = mcp_tool.model_dump(mode="json", by_alias=True, exclude_none=True) - - # Include description to detect documentation drift - payload = { - "key": tool.key, - "inputSchema": dumped["inputSchema"], - "description": dumped.get("description"), - } - - canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() -``` - -Common variations: - -| Field | When to include | -| -------------- | -------------------------------------------------------------------------- | -| `inputSchema` | Always — this is the core contract | -| `description` | When documentation drift matters (e.g. LLM routing decisions depend on it) | -| `outputSchema` | When downstream consumers validate response shapes | -| `annotations` | When behavioral hints (read-only, destructive) affect routing | -| `_meta` | When custom metadata drives policy decisions | - -## Detecting Schema Drift in CI - -Store fingerprints as artifacts and compare between deployments: - -```python -import json -import hashlib -from pathlib import Path - -from fastmcp import FastMCP - - -async def generate_manifest(server: FastMCP) -> dict[str, str]: - """Generate a fingerprint manifest for all tools.""" - manifest = {} - - for tool in await server.list_tools(): - mcp_tool = tool.to_mcp_tool() - dumped = mcp_tool.model_dump(mode="json", by_alias=True, exclude_none=True) - - payload = { - "key": tool.key, - "inputSchema": dumped["inputSchema"], - } - - canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")) - manifest[tool.key] = hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - return manifest - - -async def check_drift(server: FastMCP, baseline_path: Path) -> list[str]: - """Compare current fingerprints against a stored baseline.""" - current = await generate_manifest(server) - baseline = json.loads(baseline_path.read_text()) - - changed = [] - for key, fingerprint in current.items(): - if baseline.get(key) != fingerprint: - changed.append(key) - - for key in baseline: - if key not in current: - changed.append(key) - - return changed -``` - -Run `generate_manifest` in CI after each build and compare against the previous run. Any differences indicate a schema change that downstream consumers should be aware of. diff --git a/docs/v3/servers/tools.mdx b/docs/v3/servers/tools.mdx deleted file mode 100644 index 862066bc7..000000000 --- a/docs/v3/servers/tools.mdx +++ /dev/null @@ -1,1143 +0,0 @@ ---- -title: Tools -sidebarTitle: Tools -description: Expose functions as executable capabilities for your MCP client. -icon: wrench ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -Tools are the core building blocks that allow your LLM to interact with external systems, execute code, and access data that isn't in its training data. In FastMCP, tools are Python functions exposed to LLMs through the MCP protocol. - -Tools in FastMCP transform regular Python functions into capabilities that LLMs can invoke during conversations. When an LLM decides to use a tool: - -1. It sends a request with parameters based on the tool's schema. -2. FastMCP validates these parameters against your function's signature. -3. Your function executes with the validated inputs. -4. The result is returned to the LLM, which can use it in its response. - -This allows LLMs to perform tasks like querying databases, calling APIs, making calculations, or accessing files—extending their capabilities beyond what's in their training data. - - -## The `@tool` Decorator - -Creating a tool is as simple as decorating a Python function with `@mcp.tool`: - -```python -from fastmcp import FastMCP - -mcp = FastMCP(name="CalculatorServer") - -@mcp.tool -def add(a: int, b: int) -> int: - """Adds two integer numbers together.""" - return a + b -``` - -When this tool is registered, FastMCP automatically: -- Uses the function name (`add`) as the tool name. -- Parses the function's docstring for the tool description and, if present, per-parameter descriptions (see [Docstring Descriptions](#docstring-descriptions)). -- Generates an input schema based on the function's parameters and type annotations. -- Handles parameter validation and error reporting. - -The way you define your Python function dictates how the tool appears and behaves for the LLM client. - -<Tip> -Functions with `*args` or `**kwargs` are not supported as tools. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists. -</Tip> - -### Decorator Arguments - -While FastMCP infers the name and description from your function, you can override these and add additional metadata using arguments to the `@mcp.tool` decorator: - -```python -@mcp.tool( - name="find_products", # Custom tool name for the LLM - description="Search the product catalog with optional category filtering.", # Custom description - tags={"catalog", "search"}, # Optional tags for organization/filtering - meta={"version": "1.2", "author": "product-team"} # Custom metadata -) -def search_products_implementation(query: str, category: str | None = None) -> list[dict]: - """Internal function description (ignored if description is provided above).""" - # Implementation... - print(f"Searching for '{query}' in category '{category}'") - return [{"id": 2, "name": "Another Product"}] -``` - -<Card icon="code" title="@tool Decorator Arguments"> -<ParamField body="name" type="str | None"> - Sets the explicit tool name exposed via MCP. If not provided, uses the function name -</ParamField> - -<ParamField body="description" type="str | None"> - Provides the description exposed via MCP. If set, the function's docstring is ignored for the tool description, though docstring-derived parameter descriptions still apply (see [Docstring Descriptions](#docstring-descriptions)). -</ParamField> - -<ParamField body="tags" type="set[str] | None"> - A set of strings used to categorize the tool. These can be used by the server and, in some cases, by clients to filter or group available tools. -</ParamField> - -<ParamField body="enabled" type="bool" default="True"> - <Warning>Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead.</Warning> - A boolean to enable or disable the tool. See [Component Visibility](#component-visibility) for the recommended approach. -</ParamField> - -<ParamField body="icons" type="list[Icon] | None"> - <VersionBadge version="2.13.0" /> - - Optional list of icon representations for this tool. See [Icons](/servers/icons) for detailed examples -</ParamField> - -<ParamField body="annotations" type="ToolAnnotations | dict | None"> - An optional `ToolAnnotations` object or dictionary to add additional metadata about the tool. - <Expandable title="ToolAnnotations attributes"> - <ParamField body="title" type="str | None"> - A human-readable title for the tool. - </ParamField> - <ParamField body="readOnlyHint" type="bool | None"> - If true, the tool does not modify its environment. - </ParamField> - <ParamField body="destructiveHint" type="bool | None"> - If true, the tool may perform destructive updates to its environment. - </ParamField> - <ParamField body="idempotentHint" type="bool | None"> - If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment. - </ParamField> - <ParamField body="openWorldHint" type="bool | None"> - If true, this tool may interact with an "open world" of external entities. If false, the tool's domain of interaction is closed. - </ParamField> - </Expandable> -</ParamField> - -<ParamField body="meta" type="dict[str, Any] | None"> - <VersionBadge version="2.11.0" /> - - Optional meta information about the tool. This data is passed through to the MCP client as the `meta` field of the client-side tool object and can be used for custom metadata, versioning, or other application-specific purposes. -</ParamField> - -<ParamField body="timeout" type="float | None"> - <VersionBadge version="3.0.0" /> - - Execution timeout in seconds. If the tool takes longer than this to complete, an MCP error is returned to the client. See [Timeouts](#timeouts) for details. -</ParamField> - -<ParamField body="version" type="str | int | None"> - <VersionBadge version="3.0.0" /> - - Optional version identifier for this tool. See [Versioning](/servers/versioning) for details. -</ParamField> - -<ParamField body="output_schema" type="dict[str, Any] | None"> - <VersionBadge version="2.10.0" /> - - Optional JSON schema for the tool's output. When provided, the tool must return structured output matching this schema. If not provided, FastMCP automatically generates a schema from the function's return type annotation. See [Output Schemas](#output-schemas) for details. -</ParamField> - -<ParamField body="run_in_thread" type="bool" default="True"> - Applies to sync tool functions only. When `True` (default), sync functions are dispatched to a thread pool so they don't block the event loop. Set to `False` to run the function inline on the event loop thread — useful for libraries with thread affinity like Windows COM (`pywin32`, `uiautomation`, `comtypes`), `tkinter`, or certain GPU/driver bindings. Ignored for async functions, which always run on the event loop. See [Thread affinity](#thread-affinity) for details. -</ParamField> -</Card> - -### Using with Methods - -The `@mcp.tool` decorator registers tools immediately, which doesn't work with instance or class methods (you'd see `self` or `cls` as required parameters). For methods, use the standalone `@tool` decorator to attach metadata, then register the bound method: - -```python -from fastmcp import FastMCP -from fastmcp.tools import tool - -class Calculator: - def __init__(self, multiplier: int): - self.multiplier = multiplier - - @tool() - def multiply(self, x: int) -> int: - """Multiply x by the instance multiplier.""" - return x * self.multiplier - -calc = Calculator(multiplier=3) -mcp = FastMCP() -mcp.add_tool(calc.multiply) # Registers with correct schema (only 'x', not 'self') -``` - -### Async Support - -FastMCP supports both asynchronous (`async def`) and synchronous (`def`) functions as tools. Synchronous tools automatically run in a threadpool to avoid blocking the event loop, so multiple tool calls can execute concurrently even if individual tools perform blocking operations. - -```python -from fastmcp import FastMCP -import time - -mcp = FastMCP() - -@mcp.tool -def slow_tool(x: int) -> int: - """This sync function won't block other concurrent requests.""" - time.sleep(2) # Runs in threadpool, not on the event loop - return x * 2 -``` - -For I/O-bound operations like network requests or database queries, async tools are still preferred since they're more efficient than threadpool dispatch. Use sync tools when working with synchronous libraries or for simple operations where the threading overhead doesn't matter. - -### Thread affinity - -This section applies to sync tools only. Async tools already run on the event loop and are not affected. - -Some libraries bind state to the thread they're first used from and break when called from a different thread. The most common case is Windows COM — libraries like `uiautomation`, `comtypes`, and parts of `pywin32` require `CoInitialize` to have been called on the current thread, and worker-pool threads don't initialize COM by default. Similar constraints apply to `tkinter`, some GPU bindings (CUDA contexts), and certain hardware drivers. - -For these cases, pass `run_in_thread=False` so FastMCP invokes the sync function inline on the event loop thread instead of dispatching it to a worker: - -```python -import uiautomation as auto - -@mcp.tool(run_in_thread=False) -def list_windows() -> list[str]: - """List desktop windows via Windows UI Automation (COM).""" - desktop = auto.GetRootControl() - return [w.Name for w in desktop.GetChildren()[:5]] -``` - -The tradeoff is that the event loop is blocked for the duration of the call — other in-flight requests wait until the tool returns. Keep `run_in_thread=False` reserved for tools that genuinely need thread affinity, and prefer short-running calls in that path. - -Inline sync calls have no cancellation checkpoints, so `timeout` cannot interrupt them. Combining `timeout` with `run_in_thread=False` on a sync function is rejected at registration — drop one or the other. - -## Arguments - -By default, FastMCP converts Python functions into MCP tools by inspecting the function's signature and type annotations. This allows you to use standard Python type annotations for your tools. In general, the framework strives to "just work": idiomatic Python behaviors like parameter defaults and type annotations are automatically translated into MCP schemas. However, there are a number of ways to customize the behavior of your tools. - -<Note> -FastMCP automatically dereferences `$ref` entries in tool schemas to ensure compatibility with MCP clients that don't fully support JSON Schema references (e.g., VS Code Copilot, Claude Desktop). This means complex Pydantic models with shared types are inlined in the schema rather than using `$defs` references. - -Dereferencing happens at serve-time via middleware, so your schemas are stored with `$ref` intact and only inlined when sent to clients. If you know your clients handle `$ref` correctly and prefer smaller schemas, you can opt out: - -```python -mcp = FastMCP("my-server", dereference_schemas=False) -``` -</Note> - -### Type Annotations - -MCP tools have typed arguments, and FastMCP uses type annotations to determine those types. Therefore, you should use standard Python type annotations for tool arguments: - -```python -@mcp.tool -def analyze_text( - text: str, - max_tokens: int = 100, - language: str | None = None -) -> dict: - """Analyze the provided text.""" - # Implementation... -``` - -FastMCP supports a wide range of type annotations, including all Pydantic types: - -| Type Annotation | Example | Description | -| :---------------------- | :---------------------------- | :---------------------------------- | -| Basic types | `int`, `float`, `str`, `bool` | Simple scalar values | -| Binary data | `bytes` | Binary content (raw strings, not auto-decoded base64) | -| Date and Time | `datetime`, `date`, `timedelta` | Date and time objects (ISO format strings) | -| Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items | -| Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted | -| Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types | -| Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values | -| Paths | `Path` | File system paths (auto-converted from strings) | -| UUIDs | `UUID` | Universally unique identifiers (auto-converted from strings) | -| Pydantic models | `UserData` | Complex structured data with validation | - -FastMCP supports all types that Pydantic supports as fields, including all Pydantic custom types. A few FastMCP-specific behaviors to note: - -**Binary Data**: `bytes` parameters accept raw strings without automatic base64 decoding. For base64 data, use `str` and decode manually with `base64.b64decode()`. - -**Enums**: Clients send enum values (`"red"`), not names (`"RED"`). Your function receives the Enum member (`Color.RED`). - -**Paths and UUIDs**: String inputs are automatically converted to `Path` and `UUID` objects. - -**Pydantic Models**: Must be provided as JSON objects (dicts), not stringified JSON. Even with flexible validation, `{"user": {"name": "Alice"}}` works, but `{"user": '{"name": "Alice"}'}` does not. - -### Optional Arguments - -FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional. - -```python -@mcp.tool -def search_products( - query: str, # Required - no default value - max_results: int = 10, # Optional - has default value - sort_by: str = "relevance", # Optional - has default value - category: str | None = None # Optional - can be None -) -> list[dict]: - """Search the product catalog.""" - # Implementation... -``` - -In this example, the LLM must provide a `query` parameter, while `max_results`, `sort_by`, and `category` will use their default values if not explicitly provided. - -### Validation Modes - -<VersionBadge version="2.13.0" /> - -By default, FastMCP uses Pydantic's flexible validation that coerces compatible inputs to match your type annotations. This improves compatibility with LLM clients that may send string representations of values (like `"10"` for an integer parameter). - -If you need stricter validation that rejects any type mismatches, you can enable strict input validation. Strict mode uses the MCP SDK's built-in JSON Schema validation to validate inputs against the exact schema before passing them to your function: - -```python -# Enable strict validation for this server -mcp = FastMCP("StrictServer", strict_input_validation=True) - -@mcp.tool -def add_numbers(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - -# With strict_input_validation=True, sending {"a": "10", "b": "20"} will fail -# With strict_input_validation=False (default), it will be coerced to integers -``` - -**Validation Behavior Comparison:** - -| Input Type | strict_input_validation=False (default) | strict_input_validation=True | -| :--------- | :-------------------------------------- | :--------------------------- | -| String integers (`"10"` for `int`) | ✅ Coerced to integer | ❌ Validation error | -| String floats (`"3.14"` for `float`) | ✅ Coerced to float | ❌ Validation error | -| String booleans (`"true"` for `bool`) | ✅ Coerced to boolean | ❌ Validation error | -| Lists with string elements (`["1", "2"]` for `list[int]`) | ✅ Elements coerced | ❌ Validation error | -| Pydantic model fields with type mismatches | ✅ Fields coerced | ❌ Validation error | -| Invalid values (`"abc"` for `int`) | ❌ Validation error | ❌ Validation error | - -<Note> -**Note on Pydantic Models:** Even with `strict_input_validation=False`, Pydantic model parameters must be provided as JSON objects (dicts), not as stringified JSON. For example, `{"user": {"name": "Alice"}}` works, but `{"user": '{"name": "Alice"}'}` does not. -</Note> - -The default flexible validation mode is recommended for most use cases as it handles common LLM client behaviors gracefully while still providing strong type safety through Pydantic's validation. - -### Parameter Metadata - -You can provide additional metadata about parameters in several ways: - -#### Docstring Descriptions - -<VersionBadge version="3.2.4" /> - -FastMCP parses your function's docstring to extract both the tool description and per-parameter descriptions. Google, NumPy, and Sphinx docstring styles are all supported — the parser tries each and uses whichever finds parameter descriptions: - -```python -@mcp.tool -def process_image( - image_url: str, - resize: bool = False, - width: int = 800, -) -> dict: - """Process an image with optional resizing. - - Args: - image_url: URL of the image to process. - resize: Whether to resize the image. - width: Target width in pixels. - """ - # Implementation... -``` - -The free-form text above the `Args` section — whether a single line or multiple paragraphs — becomes the tool description, and each parameter's docstring entry becomes the description for that parameter in the generated schema. Sections like `Returns`, `Raises`, and `Example` are excluded from the description but otherwise ignored. - -If a parameter already has an explicit description — via `Annotated[x, "..."]` or `Field(description=...)` — that description takes precedence over the docstring. This makes it safe to adopt docstring-based descriptions incrementally: existing annotations keep working, and docstrings fill in the gaps. - -#### Simple String Descriptions - -<VersionBadge version="2.11.0" /> - -For basic parameter descriptions, you can use a convenient shorthand with `Annotated`: - -```python -from typing import Annotated - -@mcp.tool -def process_image( - image_url: Annotated[str, "URL of the image to process"], - resize: Annotated[bool, "Whether to resize the image"] = False, - width: Annotated[int, "Target width in pixels"] = 800, - format: Annotated[str, "Output image format"] = "jpeg" -) -> dict: - """Process an image with optional resizing.""" - # Implementation... -``` - -This shorthand syntax is equivalent to using `Field(description=...)` but more concise for simple descriptions. - -<Tip> -This shorthand syntax is only applied to `Annotated` types with a single string description. -</Tip> - -#### Advanced Metadata with Field - -For validation constraints and advanced metadata, use Pydantic's `Field` class with `Annotated`: - -```python -from typing import Annotated -from pydantic import Field - -@mcp.tool -def process_image( - image_url: Annotated[str, Field(description="URL of the image to process")], - resize: Annotated[bool, Field(description="Whether to resize the image")] = False, - width: Annotated[int, Field(description="Target width in pixels", ge=1, le=2000)] = 800, - format: Annotated[ - Literal["jpeg", "png", "webp"], - Field(description="Output image format") - ] = "jpeg" -) -> dict: - """Process an image with optional resizing.""" - # Implementation... -``` - - -You can also use the Field as a default value, though the Annotated approach is preferred: - -```python -@mcp.tool -def search_database( - query: str = Field(description="Search query string"), - limit: int = Field(10, description="Maximum number of results", ge=1, le=100) -) -> list: - """Search the database with the provided query.""" - # Implementation... -``` - -Field provides several validation and documentation features: -- `description`: Human-readable explanation of the parameter (shown to LLMs) -- `ge`/`gt`/`le`/`lt`: Greater/less than (or equal) constraints -- `min_length`/`max_length`: String or collection length constraints -- `pattern`: Regex pattern for string validation -- `default`: Default value if parameter is omitted - -### Hiding Parameters from the LLM - -<VersionBadge version="2.14.0" /> - -To inject values at runtime without exposing them to the LLM (such as `user_id`, credentials, or database connections), use dependency injection with `Depends()`. Parameters using `Depends()` are automatically excluded from the tool schema: - -```python -from fastmcp import FastMCP -from fastmcp.dependencies import Depends - -mcp = FastMCP() - -def get_user_id() -> str: - return "user_123" # Injected at runtime - -@mcp.tool -def get_user_details(user_id: str = Depends(get_user_id)) -> str: - # user_id is injected by the server, not provided by the LLM - return f"Details for {user_id}" -``` - -See [Custom Dependencies](/servers/context#custom-dependencies) for more details on dependency injection. - -## Return Values - - -FastMCP tools can return data in two complementary formats: **traditional content blocks** (like text and images) and **structured outputs** (machine-readable JSON). When you add return type annotations, FastMCP automatically generates **output schemas** to validate the structured data and enables clients to deserialize results back to Python objects. - -Understanding how these three concepts work together: - -- **Return Values**: What your Python function returns (determines both content blocks and structured data) -- **Structured Outputs**: JSON data sent alongside traditional content for machine processing -- **Output Schemas**: JSON Schema declarations that describe and validate the structured output format - -The following sections explain each concept in detail. - -### Content Blocks - -FastMCP automatically converts tool return values into appropriate MCP content blocks: - -- **`str`**: Sent as `TextContent` -- **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (within an `EmbeddedResource`) -- **`fastmcp.utilities.types.Image`**: Sent as `ImageContent` -- **`fastmcp.utilities.types.Audio`**: Sent as `AudioContent` -- **`fastmcp.utilities.types.File`**: Sent as base64-encoded `EmbeddedResource` -- **MCP SDK content blocks**: Sent as-is -- **A list of any of the above**: Converts each item according to the above rules -- **`None`**: Results in an empty response - -#### Media Helper Classes - -FastMCP provides helper classes for returning images, audio, and files. When you return one of these classes, either directly or as part of a list, FastMCP automatically converts it to the appropriate MCP content block. For example, if you return a `fastmcp.utilities.types.Image` object, FastMCP will convert it to an MCP `ImageContent` block with the correct MIME type and base64 encoding. - -```python -from fastmcp.utilities.types import Image, Audio, File - -@mcp.tool -def get_chart() -> Image: - """Generate a chart image.""" - return Image(path="chart.png") - -@mcp.tool -def get_multiple_charts() -> list[Image]: - """Return multiple charts.""" - return [Image(path="chart1.png"), Image(path="chart2.png")] -``` - -<Tip> -Helper classes are only automatically converted to MCP content blocks when returned **directly** or as part of a **list**. For more complex containers like dicts, you can manually convert them to MCP types: - -```python -# ✅ Automatic conversion -return Image(path="chart.png") -return [Image(path="chart1.png"), "text content"] - -# ❌ Will not be automatically converted -return {"image": Image(path="chart.png")} - -# ✅ Manual conversion for nested use -return {"image": Image(path="chart.png").to_image_content()} -``` -</Tip> - -Each helper class accepts either `path=` or `data=` (mutually exclusive): -- **`path`**: File path (string or Path object) - MIME type detected from extension -- **`data`**: Raw bytes - requires `format=` parameter for MIME type -- **`format`**: Optional format override (e.g., "png", "wav", "pdf") -- **`name`**: Optional name for `File` when using `data=` -- **`annotations`**: Optional MCP annotations for the content - -### Structured Output - -<VersionBadge version="2.10.0" /> - -The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content) structured content, which is a new way to return data from tools. Structured content is a JSON object that is sent alongside traditional content. FastMCP automatically creates structured outputs alongside traditional content when your tool returns data that has a JSON object representation. This provides machine-readable JSON data that clients can deserialize back to Python objects. - -**Automatic Structured Content Rules:** -- **Object-like results** (`dict`, Pydantic models, dataclasses) → Always become structured content (even without output schema) -- **Non-object results** (`int`, `str`, `list`) → Only become structured content if there's an output schema to validate/serialize them -- **All results** → Always become traditional content blocks for backward compatibility - -<Note> -This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns. -</Note> - -#### Dictionaries and Objects - -When your tool returns a dictionary, dataclass, or Pydantic model, FastMCP automatically creates structured content from it. The structured content contains the actual object data, making it easy for clients to deserialize back to native objects. - -<CodeGroup> -```python Tool Definition -@mcp.tool -def get_user_data(user_id: str) -> dict: - """Get user data.""" - return {"name": "Alice", "age": 30, "active": True} -``` - -```json MCP Result -{ - "content": [ - { - "type": "text", - "text": "{\n \"name\": \"Alice\",\n \"age\": 30,\n \"active\": true\n}" - } - ], - "structuredContent": { - "name": "Alice", - "age": 30, - "active": true - } -} -``` -</CodeGroup> - -#### Primitives and Collections - -When your tool returns a primitive type (int, str, bool) or a collection (list, set), FastMCP needs a return type annotation to generate structured content. The annotation tells FastMCP how to validate and serialize the result. - -Without a type annotation, the tool only produces `content`: - -<CodeGroup> -```python Tool Definition -@mcp.tool -def calculate_sum(a: int, b: int): - """Calculate sum without return annotation.""" - return a + b # Returns 8 -``` - -```json MCP Result -{ - "content": [ - { - "type": "text", - "text": "8" - } - ] -} -``` -</CodeGroup> - -When you add a return annotation, such as `-> int`, FastMCP generates `structuredContent` by wrapping the primitive value in a `{"result": ...}` object, since JSON schemas require object-type roots for structured output: - -<CodeGroup> -```python Tool Definition -@mcp.tool -def calculate_sum(a: int, b: int) -> int: - """Calculate sum with return annotation.""" - return a + b # Returns 8 -``` - -```json MCP Result -{ - "content": [ - { - "type": "text", - "text": "8" - } - ], - "structuredContent": { - "result": 8 - } -} -``` -</CodeGroup> - -#### Typed Models - -Return type annotations work with any type that can be converted to a JSON schema. Dataclasses and Pydantic models are particularly useful because FastMCP extracts their field definitions to create detailed schemas. - -<CodeGroup> -```python Tool Definition -from dataclasses import dataclass -from fastmcp import FastMCP - -mcp = FastMCP() - -@dataclass -class Person: - name: str - age: int - email: str - -@mcp.tool -def get_user_profile(user_id: str) -> Person: - """Get a user's profile information.""" - return Person( - name="Alice", - age=30, - email="alice@example.com", - ) -``` - -```json Generated Output Schema -{ - "properties": { - "name": {"title": "Name", "type": "string"}, - "age": {"title": "Age", "type": "integer"}, - "email": {"title": "Email", "type": "string"} - }, - "required": ["name", "age", "email"], - "title": "Person", - "type": "object" -} -``` - -```json MCP Result -{ - "content": [ - { - "type": "text", - "text": "{\"name\": \"Alice\", \"age\": 30, \"email\": \"alice@example.com\"}" - } - ], - "structuredContent": { - "name": "Alice", - "age": 30, - "email": "alice@example.com" - } -} -``` -</CodeGroup> - -The `Person` dataclass becomes an output schema (second tab) that describes the expected format. When executed, clients receive the result (third tab) with both `content` and `structuredContent` fields. - -### Output Schemas - -<VersionBadge version="2.10.0" /> - -The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) output schemas, which are a new way to describe the expected output format of a tool. When an output schema is provided, the tool *must* return structured output that matches the schema. - -When you add return type annotations to your functions, FastMCP automatically generates JSON schemas that describe the expected output format. These schemas help MCP clients understand and validate the structured data they receive. - -#### Primitive Type Wrapping - -For primitive return types (like `int`, `str`, `bool`), FastMCP automatically wraps the result under a `"result"` key to create valid structured output: - -<CodeGroup> -```python Primitive Return Type -@mcp.tool -def calculate_sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b -``` - -```json Generated Schema (Wrapped) -{ - "type": "object", - "properties": { - "result": {"type": "integer"} - }, - "x-fastmcp-wrap-result": true -} -``` - -```json Structured Output -{ - "result": 8 -} -``` -</CodeGroup> - -#### Manual Schema Control - -You can override the automatically generated schema by providing a custom `output_schema`: - -```python -@mcp.tool(output_schema={ - "type": "object", - "properties": { - "data": {"type": "string"}, - "metadata": {"type": "object"} - } -}) -def custom_schema_tool() -> dict: - """Tool with custom output schema.""" - return {"data": "Hello", "metadata": {"version": "1.0"}} -``` - -Schema generation works for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses. - -<Warning> -**Important Constraints**: -- Output schemas must be object types (`"type": "object"`) -- If you provide an output schema, your tool **must** return structured output that matches it -- However, you can provide structured output without an output schema (using `ToolResult`) -</Warning> - -### ToolResult and Metadata - -For complete control over tool responses, return a `ToolResult` object. This gives you explicit control over all aspects of the tool's output: traditional content, structured data, and metadata. - -```python -from fastmcp.tools.tool import ToolResult -from mcp.types import TextContent - -@mcp.tool -def advanced_tool() -> ToolResult: - """Tool with full control over output.""" - return ToolResult( - content=[TextContent(type="text", text="Human-readable summary")], - structured_content={"data": "value", "count": 42}, - meta={"execution_time_ms": 145} - ) -``` - -`ToolResult` accepts three fields: - -**`content`** - The traditional MCP content blocks that clients display to users. Can be a string (automatically converted to `TextContent`), a list of MCP content blocks, or any serializable value (converted to JSON string). At least one of `content` or `structured_content` must be provided. - -```python -# Simple string -ToolResult(content="Hello, world!") - -# List of content blocks -ToolResult(content=[ - TextContent(type="text", text="Result: 42"), - ImageContent(type="image", data="base64...", mimeType="image/png") -]) -``` - -**`structured_content`** - A dictionary containing structured data that matches your tool's output schema. This enables clients to programmatically process the results. If you provide `structured_content`, it must be a dictionary or `None`. If only `structured_content` is provided, it will also be used as `content` (converted to JSON string). - -```python -ToolResult( - content="Found 3 users", - structured_content={"users": [{"name": "Alice"}, {"name": "Bob"}]} -) -``` - -**`meta`** -<VersionBadge version="2.13.1" /> -Runtime metadata about the tool execution. Use this for performance metrics, debugging information, or any client-specific data that doesn't belong in the content or structured output. - -```python -ToolResult( - content="Analysis complete", - structured_content={"result": "positive"}, - meta={ - "execution_time_ms": 145, - "model_version": "2.1", - "confidence": 0.95 - } -) -``` - -<Note> -The `meta` field in `ToolResult` is for runtime metadata about tool execution (e.g., execution time, performance metrics). This is separate from the `meta` parameter in `@mcp.tool(meta={...})`, which provides static metadata about the tool definition itself. -</Note> - -When returning `ToolResult`, you have full control - FastMCP won't automatically wrap or transform your data. `ToolResult` can be returned with or without an output schema. - -### Custom Serialization - -When you need custom serialization (like YAML, Markdown tables, or specialized formats), return `ToolResult` with your serialized content. This makes the serialization explicit and visible in your tool's code: - -```python -import yaml -from fastmcp import FastMCP -from fastmcp.tools.tool import ToolResult - -mcp = FastMCP("MyServer") - -@mcp.tool -def get_config() -> ToolResult: - """Returns configuration as YAML.""" - data = {"api_key": "abc123", "debug": True, "rate_limit": 100} - return ToolResult( - content=yaml.dump(data, sort_keys=False), - structured_content=data - ) -``` - -<Tip> -For reusable serialization across multiple tools, create a wrapper decorator that returns `ToolResult`. This lets you compose serializers with other behaviors (logging, validation, caching) and keeps the serialization visible at the tool definition. See [examples/custom_tool_serializer_decorator.py](https://github.com/PrefectHQ/fastmcp/blob/main/examples/custom_tool_serializer_decorator.py) for a complete implementation. -</Tip> - -## Error Handling - -<VersionBadge version="2.4.1" /> - -If your tool encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ToolError`. - -By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately. - -If you want to mask internal error details for security reasons, you can: - -1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance: -```python -mcp = FastMCP(name="SecureServer", mask_error_details=True) -``` - -2. Or use `ToolError` to explicitly control what error information is sent to clients: -```python -from fastmcp import FastMCP -from fastmcp.exceptions import ToolError - -@mcp.tool -def divide(a: float, b: float) -> float: - """Divide a by b.""" - - if b == 0: - # Error messages from ToolError are always sent to clients, - # regardless of mask_error_details setting - raise ToolError("Division by zero is not allowed.") - - # If mask_error_details=True, this message would be masked - if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): - raise TypeError("Both arguments must be numbers.") - - return a / b -``` - -When `mask_error_details=True`, only error messages from `ToolError` will include details, other exceptions will be converted to a generic message. - -## Timeouts - -<VersionBadge version="3.0.0" /> - -Tools can specify a `timeout` parameter to limit how long execution can take. When the timeout is exceeded, the client receives an MCP error and the tool stops processing. This protects your server from unexpectedly slow operations that could block resources or leave clients waiting indefinitely. - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -@mcp.tool(timeout=30.0) -async def fetch_data(url: str) -> dict: - """Fetch data with a 30-second timeout.""" - # If this takes longer than 30 seconds, - # the client receives an MCP error - ... -``` - -Timeouts are specified in seconds as a float. When a tool exceeds its timeout, FastMCP returns an MCP error with code `-32000` and a message indicating which tool timed out and how long it ran. Both sync and async tools support timeouts—sync functions run in thread pools, so the timeout applies to the entire operation regardless of execution model. - -<Note> -Tools must explicitly opt-in to timeouts. There is no server-level default timeout setting. -</Note> - -### Timeouts vs Background Tasks - -Timeouts apply to **foreground execution**—when a tool runs directly in response to a client request. They protect your server from tools that unexpectedly hang due to network issues, resource contention, or other transient problems. - -<Warning> -The `timeout` parameter does **not** apply to background tasks. When a tool runs as a background task (`task=True`), execution happens in a Docket worker where the FastMCP timeout is not enforced. - -For task timeouts, use Docket's `Timeout` dependency directly in your function signature: - -```python -from datetime import timedelta -from docket import Timeout - -@mcp.tool(task=True) -async def long_running_task( - data: str, - timeout: Timeout = Timeout(timedelta(minutes=10)) -) -> str: - """Task with a 10-minute timeout enforced by Docket.""" - ... -``` - -See the [Docket documentation](https://chrisguidry.github.io/docket/dependencies/#task-timeouts) for more on task timeouts and retries. -</Warning> - -When a tool times out, FastMCP logs a warning suggesting task mode. For operations you know will be long-running, use `task=True` instead—background tasks offload work to distributed workers and let clients poll for progress. - -## Component Visibility - -<VersionBadge version="3.0.0" /> - -You can control which tools are enabled for clients using server-level enabled control. Disabled tools don't appear in `list_tools` and can't be called. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("MyServer") - -@mcp.tool(tags={"admin"}) -def admin_action() -> str: - """Admin-only action.""" - return "Done" - -@mcp.tool(tags={"public"}) -def public_action() -> str: - """Public action.""" - return "Done" - -# Disable specific tools by key -mcp.disable(keys={"tool:admin_action"}) - -# Disable tools by tag -mcp.disable(tags={"admin"}) - -# Or use allowlist mode - only enable tools with specific tags -mcp.enable(tags={"public"}, only=True) -``` - -See [Visibility](/servers/visibility) for the complete visibility control API including key formats, tag-based filtering, and provider-level control. - -## MCP Annotations - -<VersionBadge version="2.2.7" /> - -FastMCP allows you to add specialized metadata to your tools through annotations. These annotations communicate how tools behave to client applications without consuming token context in LLM prompts. - -Annotations serve several purposes in client applications: -- Adding user-friendly titles for display purposes -- Indicating whether tools modify data or systems -- Describing the safety profile of tools (destructive vs. non-destructive) -- Signaling if tools interact with external systems - -You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool` decorator. FastMCP accepts either a plain dict or `ToolAnnotations`; the examples below use `ToolAnnotations` for consistency and stronger editor/type support. - -```python -from mcp.types import ToolAnnotations - -@mcp.tool( - annotations=ToolAnnotations( - title="Calculate Sum", - readOnlyHint=True, - openWorldHint=False, - ) -) -def calculate_sum(a: float, b: float) -> float: - """Add two numbers together.""" - return a + b -``` - -FastMCP supports these standard annotations: - -| Annotation | Type | Default | Purpose | -| :--------- | :--- | :------ | :------ | -| `title` | string | - | Display name for user interfaces | -| `readOnlyHint` | boolean | false | Indicates if the tool only reads without making changes | -| `destructiveHint` | boolean | true | For non-readonly tools, signals if changes are destructive | -| `idempotentHint` | boolean | false | Indicates if repeated identical calls have the same effect as a single call | -| `openWorldHint` | boolean | true | Specifies if the tool interacts with external systems | - -Remember that annotations help make better user experiences but should be treated as advisory hints. They help client applications present appropriate UI elements and safety controls, but won't enforce security boundaries on their own. Always focus on making your annotations accurately represent what your tool actually does. - -### Using Annotation Hints - -MCP clients like Claude and ChatGPT use annotation hints to determine when to skip confirmation prompts and how to present tools to users. The most commonly used hint is `readOnlyHint`, which signals that a tool only reads data without making changes. - -**Read-only tools** improve user experience by: -- Skipping confirmation prompts for safe operations -- Allowing broader access without security concerns -- Enabling more aggressive batching and caching - -Mark a tool as read-only when it retrieves data, performs calculations, or checks status without modifying state: - -```python -from fastmcp import FastMCP -from mcp.types import ToolAnnotations - -mcp = FastMCP("Data Server") - -@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_user(user_id: str) -> dict: - """Retrieve user information by ID.""" - return {"id": user_id, "name": "Alice"} - -@mcp.tool( - annotations=ToolAnnotations( - readOnlyHint=True, - idempotentHint=True, # Same result for repeated calls - openWorldHint=False # Only internal data - ) -) -def search_products(query: str) -> list[dict]: - """Search the product catalog.""" - return [{"id": 1, "name": "Widget", "price": 29.99}] - -# Write operations - no readOnlyHint -@mcp.tool() -def update_user(user_id: str, name: str) -> dict: - """Update user information.""" - return {"id": user_id, "name": name, "updated": True} - -@mcp.tool(annotations=ToolAnnotations(destructiveHint=True)) -def delete_user(user_id: str) -> dict: - """Permanently delete a user account.""" - return {"deleted": user_id} -``` - -For tools that write to databases, send notifications, create/update/delete resources, or trigger workflows, omit `readOnlyHint` or set it to `False`. Use `destructiveHint=True` for operations that cannot be undone. - -Client-specific behavior: -- **ChatGPT**: Skips confirmation prompts for read-only tools in Chat mode (see [ChatGPT integration](/integrations/chatgpt)) -- **Claude**: Uses hints to understand tool safety profiles and make better execution decisions - -## Notifications - -<VersionBadge version="2.9.1" /> - -FastMCP automatically sends `notifications/tools/list_changed` notifications to connected clients when tools are added, removed, enabled, or disabled. This allows clients to stay up-to-date with the current tool set without manually polling for changes. - -```python -@mcp.tool -def example_tool() -> str: - return "Hello!" - -# These operations trigger notifications: -mcp.add_tool(example_tool) # Sends tools/list_changed notification -mcp.disable(keys={"tool:example_tool"}) # Sends tools/list_changed notification -mcp.enable(keys={"tool:example_tool"}) # Sends tools/list_changed notification -mcp.local_provider.remove_tool("example_tool") # Sends tools/list_changed notification -``` - -Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications. - -Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their tool lists or update their interfaces. - -## Accessing the MCP Context - -Tools can access MCP features like logging, reading resources, or reporting progress through the `Context` object. To use it, add a parameter to your tool function with the type hint `Context`. - -```python -from fastmcp import FastMCP, Context - -mcp = FastMCP(name="ContextDemo") - -@mcp.tool -async def process_data(data_uri: str, ctx: Context) -> dict: - """Process data from a resource with progress reporting.""" - await ctx.info(f"Processing data from {data_uri}") - - # Read a resource - resource = await ctx.read_resource(data_uri) - data = resource[0].content if resource else "" - - # Report progress - await ctx.report_progress(progress=50, total=100) - - # Example request to the client's LLM for help - summary = await ctx.sample(f"Summarize this in 10 words: {data[:200]}") - - await ctx.report_progress(progress=100, total=100) - return { - "length": len(data), - "summary": summary.text - } -``` - -The Context object provides access to: - -- **Logging**: `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()` -- **Progress Reporting**: `ctx.report_progress(progress, total)` -- **Resource Access**: `ctx.read_resource(uri)` -- **LLM Sampling**: `ctx.sample(...)` -- **Request Information**: `ctx.request_id`, `ctx.client_id` - -For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). - -## Server Behavior - -### Duplicate Tools - -<VersionBadge version="2.1.0" /> - -You can control how the FastMCP server behaves if you try to register multiple tools with the same name. This is configured using the `on_duplicate_tools` argument when creating the `FastMCP` instance. - -```python -from fastmcp import FastMCP - -mcp = FastMCP( - name="StrictServer", - # Configure behavior for duplicate tool names - on_duplicate_tools="error" -) - -@mcp.tool -def my_tool(): return "Version 1" - -# This will now raise a ValueError because 'my_tool' already exists -# and on_duplicate_tools is set to "error". -# @mcp.tool -# def my_tool(): return "Version 2" -``` - -The duplicate behavior options are: - -- `"warn"` (default): Logs a warning and the new tool replaces the old one. -- `"error"`: Raises a `ValueError`, preventing the duplicate registration. -- `"replace"`: Silently replaces the existing tool with the new one. -- `"ignore"`: Keeps the original tool and ignores the new registration attempt. - -### Removing Tools - -<VersionBadge version="2.3.4" /> - -You can dynamically remove tools from a server through its [local provider](/servers/providers/local): - -```python -from fastmcp import FastMCP - -mcp = FastMCP(name="DynamicToolServer") - -@mcp.tool -def calculate_sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - -mcp.local_provider.remove_tool("calculate_sum") -``` - -## Versioning - -<VersionBadge version="3.0.0" /> - -Tools support versioning, allowing you to maintain multiple implementations under the same name while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns. diff --git a/docs/v3/servers/transforms/code-mode.mdx b/docs/v3/servers/transforms/code-mode.mdx deleted file mode 100644 index c7ef55bf0..000000000 --- a/docs/v3/servers/transforms/code-mode.mdx +++ /dev/null @@ -1,361 +0,0 @@ ---- -title: Code Mode -sidebarTitle: Code Mode -description: Let LLMs write Python to orchestrate tools in a sandbox -icon: flask -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.1.0" /> - -<Warning> -CodeMode is experimental. The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice. -</Warning> - -Standard MCP tool usage has two scaling problems. First, every tool in the catalog is loaded into the LLM's context upfront — with hundreds of tools, that's tens of thousands of tokens spent before the LLM even reads the user's request. Second, every tool call is a round-trip: the LLM calls a tool, the result passes back through the context window, the LLM reasons about it, calls another tool, and so on. Intermediate results that only exist to feed the next step still burn tokens flowing through the model. - -CodeMode solves both problems. Instead of seeing your entire tool catalog, the LLM gets meta-tools for discovering what's available and for writing and executing code that calls the tools it needs. It discovers on demand, writes a script that chains tool calls in a sandbox, and gets back only the final answer. - -The approach was introduced by Cloudflare in [Code Mode](https://blog.cloudflare.com/code-mode/) and explored further by Anthropic in [Code Execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp). - -## Getting Started - -<Tip> -CodeMode requires the `code-mode` extra for sandbox support. Install it with `pip install "fastmcp[code-mode]"`. -</Tip> - -You take a normal server with normally registered tools and add a `CodeMode` transform. The transform wraps your existing tools in the code mode machinery — your tool functions don't change at all: - -```python -from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode - -mcp = FastMCP("Server", transforms=[CodeMode()]) - -@mcp.tool -def add(x: int, y: int) -> int: - """Add two numbers.""" - return x + y - -@mcp.tool -def multiply(x: int, y: int) -> int: - """Multiply two numbers.""" - return x * y -``` - -Clients connecting to this server no longer see `add` and `multiply` directly. Instead, they see the meta-tools that CodeMode provides — tools for discovering what's available and executing code against it. The original tools are still there, but they're accessed through the CodeMode layer. - -## Discovery - -Before the LLM can write code that calls your tools, it needs to know what tools exist and how to call them. This is the **discovery** process — the LLM uses meta-tools to learn about your tool catalog, then writes code against what it finds. - -The fundamental tradeoff is **tokens vs. round-trips**. Each discovery step is an LLM round-trip: the model calls a tool, waits for the response, reasons about it, then decides what to do next. More steps mean less wasted context (each step is targeted) but more latency and API calls. Fewer steps mean the LLM gets information upfront but pays for detail it might not need. - -By default, CodeMode gives the LLM three tools — `search`, `get_schema`, and `execute` — creating a three-stage discovery flow: - -<Steps> -<Step title="Search for tools"> -First, the LLM uses the `search` meta-tool to find tools by keyword. - -For example, it might do `search(query="math numbers")` and receive the following response: - -``` -- add: Add two numbers. -- multiply: Multiply two numbers. -``` - -This lets the LLM know which tools are available and what they do, significantly reducing the surface area it needs to consider. - -</Step> -<Step title="Get parameter details for the tools"> -Next, the LLM calls `get_schema` to get parameter details for the tools it found in the previous step. - -For example, it might do `get_schema(tools=["add", "multiply"])` and receive the following response: - -``` -### add - -Add two numbers. - -**Parameters** -- `x` (integer, required) -- `y` (integer, required) - -### multiply - -Multiply two numbers. - -**Parameters** -- `x` (integer, required) -- `y` (integer, required) -``` - -Now the LLM knows the parameters for the tools it found, and can write code that chains the tool calls. If it needed more detail, it could have called `get_schema` with `detail="full"` to get the complete JSON schema. - -</Step> -<Step title="Write and execute code that chains the tool calls"> -Finally, the LLM writes and executes code that chains the tool calls in a Python sandbox. Inside the sandbox, `call_tool(name, params)` is the only function available. The LLM uses this to compose tools into a workflow and return a final result. - -For example, it might write the following code and call the `execute` tool with it: - -```python -a = await call_tool("add", {"x": 3, "y": 4}) -b = await call_tool("multiply", {"x": a, "y": 2}) -return b -``` - -The result is returned to the LLM. -</Step> -</Steps> - -This three-stage flow works well for most servers — each step pulls in only the information needed for the next one, keeping context usage minimal. But CodeMode's discovery surface is fully configurable. The sections below explain each built-in discovery tool and how to combine them into different patterns. - -## Discovery Tools - -CodeMode ships with four built-in discovery tools: `Search`, `GetSchemas`, `GetTags`, and `ListTools`. By default, only `Search` and `GetSchemas` are enabled. Each tool supports a `default_detail` parameter that sets the default verbosity level, and the LLM can override the detail level on any individual call. - -### Detail Levels - -`Search` and `GetSchemas` share the same three detail levels, so the same `detail` value produces the same output format regardless of which tool the LLM calls: - -| Level | Output | Token cost | -|---|---|---| -| `"brief"` | Tool names and one-line descriptions | Cheapest — good for scanning | -| `"detailed"` | Compact markdown with parameter names, types, and required markers | Medium — often enough to write code | -| `"full"` | Complete JSON schema | Most expensive — everything | - -`Search` defaults to `"brief"` and `GetSchemas` defaults to `"detailed"`. - -### Search - -`Search` finds tools by natural-language query using BM25 ranking. At its default `"brief"` detail, results include just tool names and descriptions — enough to decide which tools are worth inspecting further. The LLM can request `"detailed"` to get parameter schemas inline, or `"full"` for the complete JSON. - -Search results include an annotation like `"2 of 10 tools:"` when the result set is smaller than the full catalog, so the LLM knows there are more tools to discover with different queries. - -You can cap result count with `default_limit`. The LLM can also override the limit per call. This is useful for large catalogs where you want to keep search results focused: - -```python -Search(default_limit=5) # return at most 5 results per search -``` - -If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching. - -### GetSchemas - -`GetSchemas` returns parameter details for specific tools by name. At its default `"detailed"` level, it renders compact markdown with parameter names, types, and required markers. At `"full"`, it returns the complete JSON schema — useful when tools have deeply nested parameters that the compact format doesn't capture. - -### GetTags - -`GetTags` lets the LLM browse tools by category using [tag](/servers/tools#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag: - -``` -- math (3 tools) -- text (2 tools) -- untagged (1 tool) -``` - -`GetTags` isn't included in the defaults — add it when browsing by category would help the LLM orient itself in a large catalog. The LLM can browse tags first, then pass specific tags into Search to narrow results. - -### ListTools - -`ListTools` dumps the entire catalog at whatever detail level the LLM requests. It supports the same three detail levels as `Search` and `GetSchemas`, defaulting to `"brief"`. - -`ListTools` isn't included in the defaults — for large catalogs, search-based discovery is more token-efficient. But for smaller catalogs (under ~20 tools), letting the LLM see everything upfront can be faster than multiple search round-trips: - -```python -from fastmcp.experimental.transforms.code_mode import CodeMode, ListTools, GetSchemas - -code_mode = CodeMode( - discovery_tools=[ListTools(), GetSchemas()], -) -``` - -## Discovery Patterns - -The right discovery configuration depends on your server — how many tools you have and how complex their parameters are. It may be tempting to minimize round-trips by collapsing everything into fewer steps, but for the complex servers that benefit most from CodeMode, our experience is that staged discovery leads to better results. Flooding the LLM with detailed schemas for tools it doesn't end up using can hurt more than the extra round-trip costs. Each pattern below is a complete, copyable configuration. - -### Three-Stage - -The default. The LLM searches for candidates, inspects schemas for the ones it wants, then writes code. Best for **large or complex tool sets** where you want to minimize context usage — the LLM only pays for schemas it actually needs. - -```python -from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode - -mcp = FastMCP("Server", transforms=[CodeMode()]) -``` - -If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure: - -```python -from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import GetTags, Search, GetSchemas - -code_mode = CodeMode( - discovery_tools=[GetTags(), Search(), GetSchemas()], -) - -mcp = FastMCP("Server", transforms=[code_mode]) -``` - -### Two-Stage - -Search returns parameter schemas inline, so the LLM can go straight from search to execute. Best for **smaller catalogs** where the extra tokens per search result are a reasonable price for one fewer round-trip. - -```python -from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import Search, GetSchemas - -code_mode = CodeMode( - discovery_tools=[Search(default_detail="detailed"), GetSchemas()], -) - -mcp = FastMCP("Server", transforms=[code_mode]) -``` - -`GetSchemas` is still available as a fallback — the LLM can call it with `detail="full"` if it encounters a tool with complex nested parameters where the compact markdown isn't enough. - -### Single-Stage - -Skip discovery entirely and bake tool instructions into the execute tool's description. Best for **very simple servers** where the LLM already knows what tools are available — maybe there are only a few, or they're described in the system prompt. - -```python -from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode - -code_mode = CodeMode( - discovery_tools=[], - execute_description=( - "Available tools:\n" - "- add(x: int, y: int) -> int: Add two numbers\n" - "- multiply(x: int, y: int) -> int: Multiply two numbers\n\n" - "Write Python using `await call_tool(name, params)` and `return` the result." - ), -) - -mcp = FastMCP("Server", transforms=[code_mode]) -``` - -## Custom Discovery Tools - -Discovery tools are composable — you can mix the built-ins with your own. Each discovery tool is a callable that receives catalog access and returns a `Tool`. The catalog accessor is a function (not the catalog itself) because the catalog is request-scoped — different users may see different tools based on auth. - -Here's a minimal example: - -```python -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas -from fastmcp.server.context import Context -from fastmcp.tools.tool import Tool - -def list_all_tools(get_catalog: GetToolCatalog) -> Tool: - async def list_tools(ctx: Context) -> str: - """List all available tool names.""" - tools = await get_catalog(ctx) - return ", ".join(t.name for t in tools) - - return Tool.from_function(fn=list_tools, name="list_tools") - -code_mode = CodeMode(discovery_tools=[list_all_tools, GetSchemas()]) -``` - -The LLM sees the docstring of each discovery tool's inner function as its description — that's how it learns what each tool does and when to use it. Write docstrings that explain what the tool returns and when the LLM should call it. - -Discovery tools and the execute tool can also have custom names: - -```python -from fastmcp.experimental.transforms.code_mode import Search, GetSchemas - -code_mode = CodeMode( - discovery_tools=[ - Search(name="find_tools"), - GetSchemas(name="describe"), - ], - execute_tool_name="run_workflow", -) - -mcp = FastMCP("Server", transforms=[code_mode]) -``` - -## Sandbox Configuration - -### Resource Limits - -The default `MontySandboxProvider` enforces execution limits — timeouts, memory caps, recursion depth, and more. - -Constructed with no arguments, it applies a conservative baseline so the out-of-box configuration is not unbounded: `max_duration_secs=30` and `max_memory=100_000_000` (100 MB). Pass an explicit `limits` dict to override it, or `limits=None` to run with no limits at all: - -```python -from fastmcp.experimental.transforms.code_mode import MontySandboxProvider - -MontySandboxProvider() # baseline: 30s, 100 MB -MontySandboxProvider(limits={...}) # your own limits -MontySandboxProvider(limits=None) # explicitly uncapped -``` - -```python -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import MontySandboxProvider - -sandbox = MontySandboxProvider( - limits={"max_duration_secs": 10, "max_memory": 50_000_000}, -) - -mcp = FastMCP("Server", transforms=[CodeMode(sandbox_provider=sandbox)]) -``` - -All keys are optional — omit any to leave that dimension uncapped: - -| Key | Type | Description | -|---|---|---| -| `max_duration_secs` | `float` | Maximum wall-clock execution time | -| `max_memory` | `int` | Memory ceiling in bytes | -| `max_allocations` | `int` | Cap on total object allocations | -| `max_recursion_depth` | `int` | Maximum recursion depth | -| `gc_interval` | `int` | Garbage collection frequency | - -### Tool Call Limits - -A single `execute` block can issue many `call_tool()` invocations — a loop in LLM-generated code can fan out into a large number of backend operations from one request. `CodeMode` caps this at `max_tool_calls` (default `50`); exceeding it raises a `ToolError`. Pass `None` for no cap: - -```python -from fastmcp.experimental.transforms.code_mode import CodeMode - -CodeMode() # default: 50 call_tool() calls per execute() -CodeMode(max_tool_calls=200) # raise the cap -CodeMode(max_tool_calls=None) # no cap -``` - -### Custom Sandbox Providers - -You can replace the default sandbox with any object implementing the `SandboxProvider` protocol: - -```python -from collections.abc import Callable -from typing import Any - -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import SandboxProvider - -class RemoteSandboxProvider: - async def run( - self, - code: str, - *, - inputs: dict[str, Any] | None = None, - external_functions: dict[str, Callable[..., Any]] | None = None, - ) -> Any: - # Send code to your remote sandbox runtime - ... - -mcp = FastMCP( - "Server", - transforms=[CodeMode(sandbox_provider=RemoteSandboxProvider())], -) -``` - -The `external_functions` dict contains async callables injected into the sandbox scope — `execute` uses this to provide `call_tool`. diff --git a/docs/v3/servers/transforms/namespace.mdx b/docs/v3/servers/transforms/namespace.mdx deleted file mode 100644 index fdb0d1c7f..000000000 --- a/docs/v3/servers/transforms/namespace.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Namespace Transform -sidebarTitle: Namespace -description: Prefix component names to prevent conflicts -icon: tag ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -The `Namespace` transform prefixes all component names, preventing conflicts when composing multiple servers. - -Tools and prompts receive an underscore-separated prefix. Resources and templates receive a path-segment prefix in their URIs. - -| Component | Original | With `Namespace("api")` | -|-----------|----------|-------------------------| -| Tool | `my_tool` | `api_my_tool` | -| Prompt | `my_prompt` | `api_my_prompt` | -| Resource | `data://info` | `data://api/info` | -| Template | `data://{id}` | `data://api/{id}` | - -The most common use is through the `mount()` method's `namespace` parameter. - -```python -from fastmcp import FastMCP - -weather = FastMCP("Weather") -calendar = FastMCP("Calendar") - -@weather.tool -def get_data() -> str: - return "Weather data" - -@calendar.tool -def get_data() -> str: - return "Calendar data" - -# Without namespacing, these would conflict -main = FastMCP("Main") -main.mount(weather, namespace="weather") -main.mount(calendar, namespace="calendar") - -# Clients see: weather_get_data, calendar_get_data -``` - -You can also apply namespacing directly using the `Namespace` transform. - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import Namespace - -mcp = FastMCP("Server") - -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -# Namespace all components -mcp.add_transform(Namespace("api")) - -# Tool is now: api_greet -``` diff --git a/docs/v3/servers/transforms/namespacing.mdx b/docs/v3/servers/transforms/namespacing.mdx deleted file mode 100644 index 009a1ee39..000000000 --- a/docs/v3/servers/transforms/namespacing.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Namespacing -sidebarTitle: Namespacing -description: Namespace and transform components with transforms -icon: wand-magic-sparkles -redirect: /servers/transforms/transforms ---- - -This page has moved to [Transforms](/servers/transforms/transforms). diff --git a/docs/v3/servers/transforms/prompts-as-tools.mdx b/docs/v3/servers/transforms/prompts-as-tools.mdx deleted file mode 100644 index 6a9ab1b47..000000000 --- a/docs/v3/servers/transforms/prompts-as-tools.mdx +++ /dev/null @@ -1,130 +0,0 @@ ---- -title: Prompts as Tools -sidebarTitle: Prompts as Tools -description: Expose prompts to tool-only clients -icon: message-lines -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Some MCP clients only support tools. They cannot list or get prompts directly because they lack prompt protocol support. The `PromptsAsTools` transform bridges this gap by generating tools that provide access to your server's prompts. - -When you add `PromptsAsTools` to a server, it creates two tools that clients can call instead of using the prompt protocol: - -- **`list_prompts`** returns JSON describing all available prompts and their arguments -- **`get_prompt`** renders a specific prompt with provided arguments - -This means any client that can call tools can now access prompts, even if the client has no native prompt support. - -## Basic Usage - -Pass your FastMCP server to `PromptsAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to prompt operations automatically, exactly as it would for direct `prompts/get` calls. - -<Note> -`PromptsAsTools` (and `ResourcesAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and apply the transform there. -</Note> - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import PromptsAsTools - -mcp = FastMCP("My Server") - -@mcp.prompt -def analyze_code(code: str, language: str = "python") -> str: - """Analyze code for potential issues.""" - return f"Analyze this {language} code:\n{code}" - -@mcp.prompt -def explain_concept(concept: str) -> str: - """Explain a programming concept.""" - return f"Explain: {concept}" - -# Add the transform - creates list_prompts and get_prompt tools -mcp.add_transform(PromptsAsTools(mcp)) -``` - -Clients now see three items: whatever tools you defined directly, plus `list_prompts` and `get_prompt`. - -## Listing Prompts - -The `list_prompts` tool returns JSON with metadata for each prompt, including its arguments. - -```python -result = await client.call_tool("list_prompts", {}) -prompts = json.loads(result.data) -# [ -# { -# "name": "analyze_code", -# "description": "Analyze code for potential issues.", -# "arguments": [ -# {"name": "code", "description": null, "required": true}, -# {"name": "language", "description": null, "required": false} -# ] -# }, -# { -# "name": "explain_concept", -# "description": "Explain a programming concept.", -# "arguments": [ -# {"name": "concept", "description": null, "required": true} -# ] -# } -#] -``` - -Each argument includes: -- `name`: The argument name -- `description`: Optional description from type hints or docstrings -- `required`: Whether the argument must be provided - -## Getting Prompts - -The `get_prompt` tool accepts a prompt name and optional arguments dict. It returns the rendered prompt as JSON with a messages array. - -```python -# Prompt with required and optional arguments -result = await client.call_tool( - "get_prompt", - { - "name": "analyze_code", - "arguments": { - "code": "x = 1\nprint(x)", - "language": "python" - } - } -) - -response = json.loads(result.data) -# { -# "messages": [ -# { -# "role": "user", -# "content": "Analyze this python code:\nx = 1\nprint(x)" -# } -# ] -# } -``` - -If a prompt has no arguments, you can omit the `arguments` field or pass an empty dict: - -```python -result = await client.call_tool( - "get_prompt", - {"name": "simple_prompt"} -) -``` - -## Message Format - -Rendered prompts return a messages array following the standard MCP format. Each message includes: -- `role`: The message role ("user" or "assistant") -- `content`: The message text content - -Multi-message prompts are supported - the array will contain all messages in order. - -## Binary Content - -Unlike resources, prompts always return text content. There is no binary encoding needed. diff --git a/docs/v3/servers/transforms/resources-as-tools.mdx b/docs/v3/servers/transforms/resources-as-tools.mdx deleted file mode 100644 index b79980dcc..000000000 --- a/docs/v3/servers/transforms/resources-as-tools.mdx +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: Resources as Tools -sidebarTitle: Resources as Tools -description: Expose resources to tool-only clients -icon: toolbox -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Some MCP clients only support tools. They cannot list or read resources directly because they lack resource protocol support. The `ResourcesAsTools` transform bridges this gap by generating tools that provide access to your server's resources. - -When you add `ResourcesAsTools` to a server, it creates two tools that clients can call instead of using the resource protocol: - -- **`list_resources`** returns JSON describing all available resources and templates -- **`read_resource`** reads a specific resource by URI - -This means any client that can call tools can now access resources, even if the client has no native resource support. - -## Basic Usage - -Pass your FastMCP server to `ResourcesAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to resource operations automatically, exactly as it would for direct `resources/read` calls. - -<Note> -`ResourcesAsTools` (and `PromptsAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and apply the transform there. -</Note> - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import ResourcesAsTools - -mcp = FastMCP("My Server") - -@mcp.resource("config://app") -def app_config() -> str: - """Application configuration.""" - return '{"app_name": "My App", "version": "1.0.0"}' - -@mcp.resource("user://{user_id}/profile") -def user_profile(user_id: str) -> str: - """Get a user's profile by ID.""" - return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}' - -# Add the transform - creates list_resources and read_resource tools -mcp.add_transform(ResourcesAsTools(mcp)) -``` - -Clients now see three tools: whatever tools you defined directly, plus `list_resources` and `read_resource`. - -Both generated tools are annotated with `readOnlyHint=True`, since they only read data. Clients that respect tool annotations (like Cursor) can use this to auto-confirm these tool calls without prompting the user. - -## Static Resources vs Templates - -Resources come in two forms, and the `list_resources` tool distinguishes between them in its JSON output. - -Static resources have fixed URIs. They represent concrete data that exists at a known location. In the listing output, static resources include a `uri` field containing the exact URI to request. - -Resource templates have parameterized URIs with placeholders like `{user_id}`. They represent patterns for accessing dynamic data. In the listing output, templates include a `uri_template` field showing the pattern with its placeholders. - -When a client calls `list_resources`, it receives JSON like this: - -```json -[ - { - "uri": "config://app", - "name": "app_config", - "description": "Application configuration.", - "mime_type": "text/plain" - }, - { - "uri_template": "user://{user_id}/profile", - "name": "user_profile", - "description": "Get a user's profile by ID." - } -] -``` - -The client can distinguish resource types by checking which field is present: `uri` for static resources, `uri_template` for templates. - -## Reading Resources - -The `read_resource` tool accepts a single `uri` argument. For static resources, pass the exact URI. For templates, fill in the placeholders with actual values. - -```python -# Reading a static resource -result = await client.call_tool("read_resource", {"uri": "config://app"}) -print(result.data) # '{"app_name": "My App", "version": "1.0.0"}' - -# Reading a templated resource - fill in {user_id} with an actual ID -result = await client.call_tool("read_resource", {"uri": "user://42/profile"}) -print(result.data) # '{"user_id": "42", "name": "User 42"}' -``` - -The transform handles template matching automatically. When you request `user://42/profile`, it matches against the `user://{user_id}/profile` template, extracts `user_id=42`, and calls your resource function with that parameter. - -## Binary Content - -Resources that return binary data (like images or files) are automatically base64-encoded when read through the `read_resource` tool. This ensures binary content can be transmitted as a string in the tool response. - -```python -@mcp.resource("data://binary", mime_type="application/octet-stream") -def binary_data() -> bytes: - return b"\x00\x01\x02\x03" - -# Client receives base64-encoded string -result = await client.call_tool("read_resource", {"uri": "data://binary"}) -decoded = base64.b64decode(result.data) # b'\x00\x01\x02\x03' -``` - diff --git a/docs/v3/servers/transforms/tool-search.mdx b/docs/v3/servers/transforms/tool-search.mdx deleted file mode 100644 index 204004f5c..000000000 --- a/docs/v3/servers/transforms/tool-search.mdx +++ /dev/null @@ -1,173 +0,0 @@ ---- -title: Tool Search -sidebarTitle: Tool Search -description: Replace large tool catalogs with on-demand search -icon: magnifying-glass -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.1.0" /> - -When a server exposes hundreds or thousands of tools, sending the full catalog to an LLM wastes tokens and degrades tool selection accuracy. Search transforms solve this by replacing the tool listing with a search interface — the LLM discovers tools on demand instead of receiving everything upfront. - -## How It Works - -When you add a search transform, `list_tools()` returns just two synthetic tools instead of the full catalog: - -- **`search_tools`** finds tools matching a query and returns their full definitions -- **`call_tool`** executes a discovered tool by name - -The original tools are still callable. They're hidden from the listing but remain fully functional — the search transform controls *discovery*, not *access*. - -Both synthetic tools search across tool names, descriptions, parameter names, and parameter descriptions. A search for `"email"` would match a tool named `send_email`, a tool with "email" in its description, or a tool with an `email_address` parameter. - -Search results are returned in the same JSON format as `list_tools`, including the full input schema, so the LLM can construct valid calls immediately without a second round-trip. - -## Search Strategies - -FastMCP provides two search transforms. They share the same interface — two synthetic tools, same configuration options — but differ in how they match queries to tools. - -### Regex Search - -`RegexSearchTransform` matches tools against a regex pattern using case-insensitive `re.search`. It has zero overhead and no index to build, making it a good default when the LLM knows roughly what it's looking for. - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms.search import RegexSearchTransform - -mcp = FastMCP("My Server", transforms=[RegexSearchTransform()]) - -@mcp.tool -def search_database(query: str, limit: int = 10) -> list[dict]: - """Search the database for records matching the query.""" - ... - -@mcp.tool -def delete_record(record_id: str) -> bool: - """Delete a record from the database by its ID.""" - ... - -@mcp.tool -def send_email(to: str, subject: str, body: str) -> bool: - """Send an email to the given recipient.""" - ... -``` - -The LLM's `search_tools` call takes a `pattern` parameter — a regex string: - -```python -# Exact substring match -result = await client.call_tool("search_tools", {"pattern": "database"}) -# Returns: search_database, delete_record - -# Regex pattern -result = await client.call_tool("search_tools", {"pattern": "send.*email|notify"}) -# Returns: send_email -``` - -Results are returned in catalog order. If the pattern is invalid regex, the search returns an empty list rather than raising an error. - -### BM25 Search - -`BM25SearchTransform` ranks tools by relevance using the [BM25 Okapi](https://en.wikipedia.org/wiki/Okapi_BM25) algorithm. It's better for natural language queries because it scores each tool based on term frequency and document rarity, returning results ranked by relevance rather than filtering by match/no-match. - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms.search import BM25SearchTransform - -mcp = FastMCP("My Server", transforms=[BM25SearchTransform()]) - -# ... define tools ... -``` - -The LLM's `search_tools` call takes a `query` parameter — natural language: - -```python -result = await client.call_tool("search_tools", { - "query": "tools for deleting things from the database" -}) -# Returns: delete_record ranked first, search_database second -``` - -BM25 builds an in-memory index from the searchable text of all tools. The index is created lazily on the first search and automatically rebuilt whenever the tool catalog changes — for example, when tools are added, removed, or have their descriptions updated. The staleness check is based on a hash of all searchable text, so description changes are detected even when tool names stay the same. - -### Which to Choose - -Use **regex** when your LLM is good at constructing targeted patterns and you want deterministic, predictable results. Regex is also simpler to debug — you can see exactly what pattern was sent. - -Use **BM25** when your LLM tends to describe what it needs in natural language, or when your tool catalog has nuanced descriptions where relevance ranking adds value. BM25 handles partial matches and synonyms better because it scores on individual terms rather than requiring a single pattern to match. - -## Configuration - -Both search transforms accept the same configuration options. - -### Limiting Results - -By default, search returns at most 5 tools. Adjust `max_results` based on your catalog size and how much context you want the LLM to receive per search: - -```python -mcp.add_transform(RegexSearchTransform(max_results=10)) -mcp.add_transform(BM25SearchTransform(max_results=3)) -``` - -With regex, results stop as soon as the limit is reached (first N matches in catalog order). With BM25, all tools are scored and the top N by relevance are returned. - -### Pinning Tools - -Some tools should always be visible regardless of search. Use `always_visible` to pin them in the listing alongside the synthetic tools: - -```python -mcp.add_transform(RegexSearchTransform( - always_visible=["help", "status"], -)) - -# list_tools returns: help, status, search_tools, call_tool -``` - -Pinned tools appear directly in `list_tools` so the LLM can call them without searching. They're excluded from search results to avoid duplication. - -### Custom Tool Names - -The default names `search_tools` and `call_tool` can be changed to avoid conflicts with real tools: - -```python -mcp.add_transform(RegexSearchTransform( - search_tool_name="find_tools", - call_tool_name="run_tool", -)) -``` - -## The `call_tool` Proxy - -The `call_tool` proxy forwards calls to the real tool. When a client calls `call_tool(name="search_database", arguments={...})`, the proxy resolves `search_database` through the server's normal tool pipeline — including transforms and middleware — and executes it. - -The proxy rejects attempts to call the synthetic tools themselves. `call_tool(name="call_tool")` raises an error rather than recursing. - -<Note> -Tools discovered through search can also be called directly via `client.call_tool("search_database", {...})` without going through the proxy. The proxy exists for LLMs that only know about the tools returned by `list_tools` and need a way to invoke discovered tools through a tool they can see. -</Note> - -## Auth and Visibility - -Search results respect the full authorization pipeline. Tools filtered by middleware, visibility transforms, or component-level auth checks won't appear in search results. - -The search tool queries `list_tools()` through the complete pipeline at search time, so the same filtering that controls what a client sees in the listing also controls what they can discover through search. - -```python -from fastmcp.server.transforms import Visibility -from fastmcp.server.transforms.search import RegexSearchTransform - -mcp = FastMCP("My Server") - -# ... define tools ... - -# Disable admin tools globally -mcp.add_transform(Visibility(False, tags={"admin"})) - -# Add search — admin tools won't appear in results -mcp.add_transform(RegexSearchTransform()) -``` - -Session-level visibility changes (via `ctx.disable_components()`) are also reflected immediately in search results. diff --git a/docs/v3/servers/transforms/tool-transformation.mdx b/docs/v3/servers/transforms/tool-transformation.mdx deleted file mode 100644 index a50513f87..000000000 --- a/docs/v3/servers/transforms/tool-transformation.mdx +++ /dev/null @@ -1,230 +0,0 @@ ---- -title: Tool Transformation -sidebarTitle: Tool Transformation -description: Modify tool schemas - rename, reshape arguments, and customize behavior -icon: wrench ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Tool transformation lets you modify tool schemas - renaming tools, changing descriptions, adjusting tags, and reshaping argument schemas. FastMCP provides two mechanisms that share the same configuration options but differ in timing. - -**Deferred transformation** with `ToolTransform` applies modifications when tools flow through a transform chain. Use this for tools from mounted servers, proxies, or other providers where you don't control the source directly. - -**Immediate transformation** with `Tool.from_tool()` creates a modified tool object right away. Use this when you have direct access to a tool and want to transform it before registration. - -## ToolTransform - -The `ToolTransform` class is a transform that modifies tools as they flow through a provider. Provide a dictionary mapping original tool names to their transformation configuration. - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import ToolTransform -from fastmcp.tools.tool_transform import ToolTransformConfig - -mcp = FastMCP("Server") - -@mcp.tool -def verbose_internal_data_fetcher(query: str) -> str: - """Fetches data from the internal database.""" - return f"Results for: {query}" - -# Rename the tool to something simpler -mcp.add_transform(ToolTransform({ - "verbose_internal_data_fetcher": ToolTransformConfig( - name="search", - description="Search the database.", - ) -})) - -# Clients see "search" with the cleaner description -``` - -`ToolTransform` is useful when you want to modify tools from mounted or proxied servers without changing the original source. - -## Tool.from_tool() - -Use `Tool.from_tool()` when you have the tool object and want to create a transformed version for registration. - -```python -from fastmcp import FastMCP -from fastmcp.tools import Tool, tool -from fastmcp.tools.tool_transform import ArgTransform - -# Create a tool without registering it -@tool -def search(q: str, limit: int = 10) -> list[str]: - """Search for items.""" - return [f"Result {i} for {q}" for i in range(limit)] - -# Transform it before registration -better_search = Tool.from_tool( - search, - name="find_items", - description="Find items matching your search query.", - transform_args={ - "q": ArgTransform( - name="query", - description="The search terms to look for.", - ), - }, -) - -mcp = FastMCP("Server") -mcp.add_tool(better_search) -``` - -The standalone `@tool` decorator (from `fastmcp.tools`) creates a Tool object without registering it to any server. This separates creation from registration, letting you transform tools before deciding where they go. - -## Modification Options - -Both mechanisms support the same modifications. - -**Tool-level options:** - -| Option | Description | -|--------|-------------| -| `name` | New name for the tool | -| `description` | New description | -| `title` | Human-readable title | -| `tags` | Set of tags for categorization | -| `annotations` | MCP ToolAnnotations | -| `meta` | Custom metadata dictionary | -| `enabled` | Whether the tool is visible to clients (default `True`) | - -**Argument-level options** (via `ArgTransform` or `ArgTransformConfig`): - -| Option | Description | -|--------|-------------| -| `name` | Rename the argument | -| `description` | New description for the argument | -| `default` | New default value | -| `default_factory` | Callable that generates a default (requires `hide=True`) | -| `hide` | Remove from client-visible schema | -| `required` | Make an optional argument required | -| `type` | Change the argument's type | -| `examples` | Example values for the argument | - -## Hiding Arguments - -Hide arguments to simplify the interface or inject values the client shouldn't control. - -```python -from fastmcp.tools.tool_transform import ArgTransform - -# Hide with a constant value -transform_args = { - "api_key": ArgTransform(hide=True, default="secret-key"), -} - -# Hide with a dynamic value -import uuid -transform_args = { - "request_id": ArgTransform(hide=True, default_factory=lambda: str(uuid.uuid4())), -} -``` - -Hidden arguments disappear from the tool's schema. The client never sees them, but the underlying function receives the configured value. - -<Warning> -`default_factory` requires `hide=True`. Visible arguments need static defaults that can be represented in JSON Schema. -</Warning> - -## Renaming Arguments - -Rename arguments to make them more intuitive for LLMs or match your API conventions. - -```python -from fastmcp.tools import Tool, tool -from fastmcp.tools.tool_transform import ArgTransform - -@tool -def search(q: str, n: int = 10) -> list[str]: - """Search for items.""" - return [] - -better_search = Tool.from_tool( - search, - transform_args={ - "q": ArgTransform(name="query", description="Search terms"), - "n": ArgTransform(name="max_results", description="Maximum results to return"), - }, -) -``` - -## Custom Transform Functions - -For advanced scenarios, provide a `transform_fn` that intercepts tool execution. The function can validate inputs, modify outputs, or add custom logic while still calling the original tool via `forward()`. - -```python -from fastmcp import FastMCP -from fastmcp.tools import Tool, tool -from fastmcp.tools.tool_transform import forward, ArgTransform - -@tool -def divide(a: float, b: float) -> float: - """Divide a by b.""" - return a / b - -async def safe_divide(numerator: float, denominator: float) -> float: - if denominator == 0: - raise ValueError("Cannot divide by zero") - return await forward(numerator=numerator, denominator=denominator) - -safe_division = Tool.from_tool( - divide, - name="safe_divide", - transform_fn=safe_divide, - transform_args={ - "a": ArgTransform(name="numerator"), - "b": ArgTransform(name="denominator"), - }, -) - -mcp = FastMCP("Server") -mcp.add_tool(safe_division) -``` - -The `forward()` function handles argument mapping automatically. Call it with the transformed argument names, and it maps them back to the original function's parameters. - -For direct access to the original function without mapping, use `forward_raw()` with the original parameter names. - -## Context-Aware Tool Factories - -You can write functions that act as "factories," generating specialized versions of a tool for different contexts. For example, create a `get_my_data` tool for the current user by hiding the `user_id` parameter and providing it automatically. - -```python -from fastmcp import FastMCP -from fastmcp.tools import Tool, tool -from fastmcp.tools.tool_transform import ArgTransform - -# A generic tool that requires a user_id -@tool -def get_user_data(user_id: str, query: str) -> str: - """Fetch data for a specific user.""" - return f"Data for user {user_id}: {query}" - - -def create_user_tool(user_id: str) -> Tool: - """Factory that creates a user-specific version of get_user_data.""" - return Tool.from_tool( - get_user_data, - name="get_my_data", - description="Fetch your data. No need to specify a user ID.", - transform_args={ - "user_id": ArgTransform(hide=True, default=user_id), - }, - ) - - -# Create a server with a tool customized for the current user -mcp = FastMCP("User Server") -current_user_id = "user-123" # e.g., from auth context -mcp.add_tool(create_user_tool(current_user_id)) - -# Clients see "get_my_data(query: str)" — user_id is injected automatically -``` - -This pattern is useful for multi-tenant servers where each connection gets tools pre-configured with their identity, or for wrapping generic tools with environment-specific defaults. diff --git a/docs/v3/servers/transforms/transforms.mdx b/docs/v3/servers/transforms/transforms.mdx deleted file mode 100644 index 4347b2f18..000000000 --- a/docs/v3/servers/transforms/transforms.mdx +++ /dev/null @@ -1,173 +0,0 @@ ---- -title: Transforms Overview -sidebarTitle: Overview -description: Modify components as they flow through your server -icon: wand-magic-sparkles ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Transforms modify components as they flow from providers to clients. When a client asks "what tools do you have?", the request passes through each transform in the chain. Each transform can modify the components before passing them along. - -## Mental Model - -Think of transforms as filters in a pipeline. Components flow from providers through transforms to reach clients: - -``` -Provider → [Transform A] → [Transform B] → Client -``` - -When listing components, transforms receive sequences and return transformed sequences—a pure function pattern. When getting a specific component by name, transforms use a middleware pattern with `call_next`, working in reverse: mapping the client's requested name back to the original, then transforming the result. - -## Built-in Transforms - -FastMCP provides several transforms for common use cases: - -- **[Namespace](/servers/transforms/namespace)** - Prefix component names to prevent conflicts when composing servers -- **[Tool Transformation](/servers/transforms/tool-transformation)** - Rename tools, modify descriptions, reshape arguments -- **[Enabled](/servers/visibility)** - Control which components are visible at runtime -- **[Tool Search](/servers/transforms/tool-search)** - Replace large tool catalogs with on-demand search -- **[Resources as Tools](/servers/transforms/resources-as-tools)** - Expose resources to tool-only clients -- **[Prompts as Tools](/servers/transforms/prompts-as-tools)** - Expose prompts to tool-only clients -- **[Code Mode (Experimental)](/servers/transforms/code-mode)** - Replace many tools with programmable `search` + `execute` - -## Server vs Provider Transforms - -Transforms can be added at two levels, each serving different purposes. - -### Provider-Level Transforms - -Provider transforms apply to components from a specific provider. They run first, modifying components before they reach the server level. - -```python -from fastmcp import FastMCP -from fastmcp.server.providers import FastMCPProvider -from fastmcp.server.transforms import Namespace, ToolTransform -from fastmcp.tools.tool_transform import ToolTransformConfig - -sub_server = FastMCP("Sub") - -@sub_server.tool -def process(data: str) -> str: - return f"Processed: {data}" - -# Create provider and add transforms -provider = FastMCPProvider(sub_server) -provider.add_transform(Namespace("api")) -provider.add_transform(ToolTransform({ - "api_process": ToolTransformConfig(description="Process data through the API"), -})) - -main = FastMCP("Main", providers=[provider]) -# Tool is now: api_process with updated description -``` - -When using `mount()`, the returned provider reference lets you add transforms directly. - -```python -main = FastMCP("Main") -mount = main.mount(sub_server, namespace="api") -mount.add_transform(ToolTransform({...})) -``` - -### Server-Level Transforms - -Server transforms apply to all components from all providers. They run after provider transforms, seeing the already-transformed names. - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import Namespace - -mcp = FastMCP("Server", transforms=[Namespace("v1")]) - -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -# All tools become v1_toolname -``` - -Server-level transforms are useful for API versioning or applying consistent naming across your entire server. - -### Transform Order - -Transforms stack in the order they're added. The first transform added is innermost (closest to the provider), and subsequent transforms wrap it. - -```python -from fastmcp.server.providers import FastMCPProvider -from fastmcp.server.transforms import Namespace, ToolTransform -from fastmcp.tools.tool_transform import ToolTransformConfig - -provider = FastMCPProvider(server) -provider.add_transform(Namespace("api")) # Applied first -provider.add_transform(ToolTransform({ # Sees namespaced names - "api_verbose_name": ToolTransformConfig(name="short"), -})) - -# Flow: "verbose_name" -> "api_verbose_name" -> "short" -``` - -When a client requests "short", the transforms reverse the mapping: ToolTransform maps "short" to "api_verbose_name", then Namespace strips the prefix to find "verbose_name" in the provider. - -## Custom Transforms - -Create custom transforms by subclassing `Transform` and overriding the methods you need. - -```python -from collections.abc import Sequence -from fastmcp.server.transforms import Transform, GetToolNext -from fastmcp.tools.tool import Tool - -class TagFilter(Transform): - """Filter tools to only those with specific tags.""" - - def __init__(self, required_tags: set[str]): - self.required_tags = required_tags - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - return [t for t in tools if t.tags & self.required_tags] - - async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: - tool = await call_next(name) - if tool and tool.tags & self.required_tags: - return tool - return None -``` - -The `Transform` base class provides default implementations that pass through unchanged. Override only the methods relevant to your transform. - -Each component type has two methods with different patterns: - -| Method | Pattern | Purpose | -|--------|---------|---------| -| `list_tools(tools)` | Pure function | Transform the sequence of tools | -| `get_tool(name, call_next)` | Middleware | Transform lookup by name | -| `list_resources(resources)` | Pure function | Transform the sequence of resources | -| `get_resource(uri, call_next)` | Middleware | Transform lookup by URI | -| `list_resource_templates(templates)` | Pure function | Transform the sequence of templates | -| `get_resource_template(uri, call_next)` | Middleware | Transform template lookup by URI | -| `list_prompts(prompts)` | Pure function | Transform the sequence of prompts | -| `get_prompt(name, call_next)` | Middleware | Transform lookup by name | - -List methods receive sequences directly and return transformed sequences. Get methods use `call_next` for routing flexibility—when a client requests "new_name", your transform maps it back to "original_name" before calling `call_next()`. - -```python -class PrefixTransform(Transform): - def __init__(self, prefix: str): - self.prefix = prefix - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - return [t.model_copy(update={"name": f"{self.prefix}_{t.name}"}) for t in tools] - - async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: - # Reverse the prefix to find the original - if not name.startswith(f"{self.prefix}_"): - return None - original = name[len(self.prefix) + 1:] - tool = await call_next(original) - if tool: - return tool.model_copy(update={"name": name}) - return None -``` diff --git a/docs/v3/servers/versioning.mdx b/docs/v3/servers/versioning.mdx deleted file mode 100644 index 4c44a73bd..000000000 --- a/docs/v3/servers/versioning.mdx +++ /dev/null @@ -1,336 +0,0 @@ ---- -title: Versioning -sidebarTitle: Versioning -description: Serve multiple API versions from a single codebase -icon: code-branch -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Component versioning lets you maintain multiple implementations of the same tool, resource, or prompt under a single identifier. You register each version, and FastMCP handles the rest: clients see the highest version by default, but you can filter to expose exactly the versions you want. - -The primary use case is serving different API versions from one codebase. Instead of maintaining separate deployments for v1 and v2 clients, you version your components and use `VersionFilter` to create distinct API surfaces. - -## Versioned API Surfaces - -Consider a server that needs to support both v1 and v2 clients. The v2 API adds new parameters to existing tools, and you want both versions to coexist cleanly. Define your components on a shared provider, then create separate servers with different version filters. - -```python -from fastmcp import FastMCP -from fastmcp.server.providers import LocalProvider -from fastmcp.server.transforms import VersionFilter - -# Define versioned components on a shared provider -components = LocalProvider() - -@components.tool(version="1.0") -def calculate(x: int, y: int) -> int: - """Add two numbers.""" - return x + y - -@components.tool(version="2.0") -def calculate(x: int, y: int, z: int = 0) -> int: - """Add two or three numbers.""" - return x + y + z - -# Create servers that share the provider with different filters -api_v1 = FastMCP("API v1", providers=[components]) -api_v1.add_transform(VersionFilter(version_lt="2.0")) - -api_v2 = FastMCP("API v2", providers=[components]) -api_v2.add_transform(VersionFilter(version_gte="2.0")) -``` - -Clients connecting to `api_v1` see the two-argument `calculate`. Clients connecting to `api_v2` see the three-argument version. Both servers share the same component definitions. - -`VersionFilter` accepts two keyword-only parameters that mirror comparison operators: `version_gte` (greater than or equal) and `version_lt` (less than). You can use either or both to define your version range. - -```python -# Versions < 3.0 (v1.x and v2.x) -VersionFilter(version_lt="3.0") - -# Versions >= 2.0 (v2.x and later) -VersionFilter(version_gte="2.0") - -# Versions in range [2.0, 3.0) (only v2.x) -VersionFilter(version_gte="2.0", version_lt="3.0") -``` - -<Note> -**Unversioned components are exempt from version filtering by default.** Set `include_unversioned=False` to exclude them. Including them by default ensures that adding version filtering to a server with mixed versioned and unversioned components doesn't accidentally hide the unversioned ones. To prevent confusion, FastMCP forbids mixing versioned and unversioned components with the same name. -</Note> - -### Filtering Mounted Servers - -When you mount child servers and apply a `VersionFilter` to the parent, the filter applies to components from mounted servers as well. Range filtering (`version_gte` and `version_lt`) is handled at the provider level, meaning mounted servers don't need to know about the parent's version constraints. - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import VersionFilter - -# Child server with versioned components -child = FastMCP("Child") - -@child.tool(version="1.0") -def process(data: str) -> str: - return data.upper() - -@child.tool(version="2.0") -def process(data: str, mode: str = "default") -> str: - return data.upper() if mode == "default" else data.lower() - -# Parent server mounts child and applies version filter -parent = FastMCP("Parent") -parent.mount(child, namespace="child") -parent.add_transform(VersionFilter(version_lt="2.0")) - -# Clients see only child_process v1.0 -``` - -The parent's `VersionFilter` sees components after they've been namespaced, but filters based on version regardless of namespace. This lets you apply version policies consistently across your entire server hierarchy. - -## Declaring Versions - -Add a `version` parameter to any component decorator. FastMCP stores versions as strings and groups components by their identifier (name for tools and prompts, URI for resources). - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -@mcp.tool(version="1.0") -def process(data: str) -> str: - """Original processing.""" - return data.upper() - -@mcp.tool(version="2.0") -def process(data: str, mode: str = "default") -> str: - """Enhanced processing with mode selection.""" - if mode == "reverse": - return data[::-1].upper() - return data.upper() -``` - -Both versions are registered. When a client lists tools, they see only `process` with version 2.0 (the highest). When they invoke `process`, version 2.0 executes. The same pattern applies to resources and prompts. - -### Versioned vs Unversioned Components - -For any given component name, you must choose one approach: either version all implementations or version none of them. Mixing versioned and unversioned components with the same name raises an error at registration time. - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -@mcp.tool -def calculate(x: int, y: int) -> int: - """Unversioned tool.""" - return x + y - -@mcp.tool(version="2.0") # Raises ValueError -def calculate(x: int, y: int, z: int = 0) -> int: - """Cannot mix versioned with unversioned.""" - return x + y + z -``` - -The error message explains the conflict: "Cannot add versioned tool 'calculate' (version='2.0'): an unversioned tool with this name already exists. Either version all components or none." - -This restriction helps keep version filtering behavior predictable. - -Resources and prompts follow the same pattern. - -```python -@mcp.resource("config://app", version="1.0") -def config_v1() -> str: - return '{"format": "legacy"}' - -@mcp.resource("config://app", version="2.0") -def config_v2() -> str: - return '{"format": "modern", "schema": "v2"}' - -@mcp.prompt(version="1.0") -def summarize(text: str) -> str: - return f"Summarize: {text}" - -@mcp.prompt(version="2.0") -def summarize(text: str, style: str = "concise") -> str: - return f"Summarize in a {style} style: {text}" -``` - -### Version Discovery - -When clients list components, each versioned component includes metadata about all available versions. This lets clients discover what versions exist before deciding which to use. The `meta.fastmcp.versions` field contains all registered versions sorted from highest to lowest. - -```python -from fastmcp import Client - -async with Client(server) as client: - tools = await client.list_tools() - - for tool in tools: - if tool.meta: - fastmcp_meta = tool.meta.get("fastmcp", {}) - # Current version being returned (highest by default) - print(f"Version: {fastmcp_meta.get('version')}") - # All available versions for this component - print(f"Available: {fastmcp_meta.get('versions')}") -``` - -For a tool with versions `"1.0"` and `"2.0"`, listing returns the `2.0` implementation with `meta.fastmcp.version` set to `"2.0"` and `meta.fastmcp.versions` set to `["2.0", "1.0"]`. Unversioned components omit these fields entirely. - -This discovery mechanism enables clients to make informed decisions about which version to request, support graceful degradation when newer versions introduce breaking changes, or display version information in developer tools. - -## Requesting Specific Versions - -By default, clients receive and invoke the highest version of each component. When you need a specific version, FastMCP provides two approaches: the FastMCP client API for Python applications, and the MCP protocol mechanism for any MCP-compatible client. - -### FastMCP Client - -The FastMCP client's `call_tool` and `get_prompt` methods accept an optional `version` parameter. When specified, the server executes that exact version instead of the highest. - -```python -from fastmcp import Client - -async with Client(server) as client: - # Call the highest version (default behavior) - result = await client.call_tool("calculate", {"x": 1, "y": 2}) - - # Call a specific version - result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0") - - # Get a specific prompt version - prompt = await client.get_prompt("summarize", {"text": "..."}, version="1.0") -``` - -If the requested version doesn't exist, the server raises a `NotFoundError`. This ensures you get exactly what you asked for rather than silently falling back to a different version. - -### MCP Protocol - -For generic MCP clients that don't have built-in version support, pass the version through the `_meta` field in arguments. FastMCP servers extract the version from `_meta.fastmcp.version` before processing. - -<CodeGroup> -```json Tool Call Arguments -{ - "x": 1, - "y": 2, - "_meta": { - "fastmcp": { - "version": "1.0" - } - } -} -``` - -```json Prompt Arguments -{ - "text": "Summarize this document...", - "_meta": { - "fastmcp": { - "version": "1.0" - } - } -} -``` -</CodeGroup> - -The `_meta` field is part of the MCP request params, not arguments, so your component implementation never sees it. This convention allows version selection to work across any MCP client without requiring protocol changes. The FastMCP client handles this automatically when you pass the `version` parameter. - -## Version Comparison - -FastMCP compares versions to determine which is "highest" when multiple versions share an identifier. The comparison behavior depends on the version format. - -For [PEP 440](https://peps.python.org/pep-0440/) versions (like `"1.0"`, `"2.1.3"`, `"1.0a1"`), FastMCP uses semantic comparison where numeric segments are compared as numbers. - -```python -# PEP 440 versions compare semantically -"1" < "2" < "10" # Numeric order (not "1" < "10" < "2") -"1.9" < "1.10" # Numeric order (not "1.10" < "1.9") -"1.0a1" < "1.0b1" < "1.0" # Pre-releases sort before releases -``` - -For other formats (dates, custom schemes), FastMCP falls back to lexicographic string comparison. This works well for ISO dates and other naturally sortable formats. - -```python -# Non-PEP 440 versions compare as strings -"2025-01-15" < "2025-02-01" # ISO dates sort correctly -"alpha" < "beta" # Alphabetical order -``` - -The `v` prefix is stripped before comparison, so `"v1.0"` and `"1.0"` are treated as equal for sorting purposes. - -## Retrieving Specific Versions - -Server-side code can retrieve specific versions rather than just the highest. This is useful during migrations when you need to compare behavior between versions or access legacy implementations. - -The `get_tool`, `get_resource`, and `get_prompt` methods accept an optional `version` parameter. Without it, they return the highest version. With it, they return exactly that version. - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -@mcp.tool(version="1.0") -def add(x: int, y: int) -> int: - return x + y - -@mcp.tool(version="2.0") -def add(x: int, y: int) -> int: - return x + y + 100 # Different behavior - -# Get highest version (default) -tool = await mcp.get_tool("add") -print(tool.version) # "2.0" - -# Get specific version -tool_v1 = await mcp.get_tool("add", version="1.0") -print(tool_v1.version) # "1.0" -``` - -If the requested version doesn't exist, a `NotFoundError` is raised. - -## Removing Versions - -The `remove_tool`, `remove_resource`, and `remove_prompt` methods on the server's [local provider](/servers/providers/local) accept an optional `version` parameter that controls what gets removed. - -```python -# Remove ALL versions of a component -mcp.local_provider.remove_tool("calculate") - -# Remove only a specific version -mcp.local_provider.remove_tool("calculate", version="1.0") -``` - -When you remove a specific version, other versions remain registered. When you remove without specifying a version, all versions are removed. - -## Migration Workflow - -Versioning supports gradual migration when updating component behavior. You can deploy new versions alongside old ones, verify the new behavior works correctly, then clean up. - -When migrating an existing unversioned component to use versioning, start by assigning an initial version to your existing implementation. Then add the new version alongside it. - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -@mcp.tool(version="1.0") -def process_data(input: str) -> str: - """Original implementation, now versioned.""" - return legacy_process(input) - -@mcp.tool(version="2.0") -def process_data(input: str, options: dict | None = None) -> str: - """Updated implementation with new options parameter.""" - return modern_process(input, options or {}) -``` - -Clients automatically see version 2.0 (the highest). During the transition, your server code can still access the original implementation via `get_tool("process_data", version="1.0")`. - -Once the migration is complete, remove the old version. - -```python -mcp.local_provider.remove_tool("process_data", version="1.0") -``` diff --git a/docs/v3/servers/visibility.mdx b/docs/v3/servers/visibility.mdx deleted file mode 100644 index 509bd7068..000000000 --- a/docs/v3/servers/visibility.mdx +++ /dev/null @@ -1,452 +0,0 @@ ---- -title: Component Visibility -sidebarTitle: Visibility -description: Control which components are available to clients -icon: toggle-on -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - -<VersionBadge version="3.0.0" /> - -Components can be dynamically enabled or disabled at runtime. A disabled tool disappears from listings and cannot be called. This enables runtime access control, feature flags, and context-aware component exposure. - -## Component Visibility - -Every FastMCP server provides `enable()` and `disable()` methods for controlling component availability. - -### Disabling Components - -The `disable()` method marks components as disabled. Disabled components are filtered out from all client queries. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("Server") - -@mcp.tool(tags={"admin"}) -def delete_everything() -> str: - """Delete all data.""" - return "Deleted" - -@mcp.tool(tags={"admin"}) -def reset_system() -> str: - """Reset the system.""" - return "Reset" - -@mcp.tool -def get_status() -> str: - """Get system status.""" - return "OK" - -# Disable admin tools -mcp.disable(tags={"admin"}) - -# Clients only see: get_status -``` - -### Enabling Components - -The `enable()` method re-enables previously disabled components. - -```python -# Re-enable admin tools -mcp.enable(tags={"admin"}) - -# Clients now see all three tools -``` - -## Keys and Tags - -Visibility filtering works with two identifiers: keys (for specific components) and tags (for groups). - -### Component Keys - -Every component has a unique key in the format `{type}:{identifier}`. - -| Component | Key Format | Example | -|-----------|------------|---------| -| Tool | `tool:{name}` | `tool:delete_everything` | -| Resource | `resource:{uri}` | `resource:data://config` | -| Template | `template:{uri}` | `template:file://{path}` | -| Prompt | `prompt:{name}` | `prompt:analyze` | - -Use keys to target specific components. - -```python -# Disable a specific tool -mcp.disable(keys={"tool:delete_everything"}) - -# Disable multiple specific components -mcp.disable(keys={"tool:reset_system", "resource:data://secrets"}) -``` - -### Tags - -Tags group components for bulk operations. Define tags when creating components, then filter by them. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("Server") - -@mcp.tool(tags={"public", "read"}) -def get_data() -> str: - return "data" - -@mcp.tool(tags={"admin", "write"}) -def set_data(value: str) -> str: - return f"Set: {value}" - -@mcp.tool(tags={"admin", "dangerous"}) -def delete_data() -> str: - return "Deleted" - -# Disable all admin tools -mcp.disable(tags={"admin"}) - -# Disable all dangerous tools (some overlap with admin) -mcp.disable(tags={"dangerous"}) -``` - -A component is disabled if it has **any** of the disabled tags. The component doesn't need all the tags; one match is enough. - -### Combining Keys and Tags - -You can specify both keys and tags in a single call. The filters combine additively. - -```python -# Disable specific tools AND all dangerous-tagged components -mcp.disable(keys={"tool:debug_info"}, tags={"dangerous"}) -``` - -## Allowlist Mode - -By default, visibility filtering uses blocklist mode: everything is enabled unless explicitly disabled. The `only=True` parameter switches to allowlist mode, where **only** specified components are enabled. - -```python -from fastmcp import FastMCP - -mcp = FastMCP("Server") - -@mcp.tool(tags={"safe"}) -def read_only_operation() -> str: - return "Read" - -@mcp.tool(tags={"safe"}) -def list_items() -> list[str]: - return ["a", "b", "c"] - -@mcp.tool(tags={"dangerous"}) -def delete_all() -> str: - return "Deleted" - -@mcp.tool -def untagged_tool() -> str: - return "Untagged" - -# Only enable safe tools - everything else is disabled -mcp.enable(tags={"safe"}, only=True) - -# Clients see: read_only_operation, list_items -# Disabled: delete_all, untagged_tool -``` - -Allowlist mode is useful for restrictive environments where you want to explicitly opt-in components rather than opt-out. - -### Allowlist Behavior - -When you call `enable(only=True)`: - -1. Default visibility state switches to "disabled" -2. Previous allowlists are cleared -3. Only specified keys/tags become enabled - -```python -# Start fresh - only enable these specific tools -mcp.enable(keys={"tool:safe_read", "tool:safe_write"}, only=True) - -# Later, switch to a different allowlist -mcp.enable(tags={"production"}, only=True) -``` - -### Ordering and Overrides - -Later `enable()` and `disable()` calls override earlier ones. This lets you create broad rules with specific exceptions. - -```python -mcp.enable(tags={"api"}, only=True) # Allow all api-tagged -mcp.disable(keys={"tool:api_admin"}) # Later disable overrides for this tool - -# api_admin is disabled because the later disable() overrides the allowlist -``` - -You can always re-enable something that was disabled by adding another `enable()` call after it. - -## Server vs Provider - -Visibility state operates at two levels: the server and individual providers. - -### Server-Level - -Server-level visibility state applies to all components from all providers. When you call `mcp.enable()` or `mcp.disable()`, you're filtering the final view that clients see. - -```python -from fastmcp import FastMCP - -main = FastMCP("Main") -main.mount(sub_server, namespace="api") - -@main.tool(tags={"internal"}) -def local_debug() -> str: - return "Debug" - -# Disable internal tools from ALL sources -main.disable(tags={"internal"}) -``` - -### Provider-Level - -Each provider can add its own visibility transforms. These run before server-level transforms, so the server can override provider-level disables. - -```python -from fastmcp import FastMCP -from fastmcp.server.providers import LocalProvider - -# Create provider with visibility control -admin_tools = LocalProvider() - -@admin_tools.tool(tags={"admin"}) -def admin_action() -> str: - return "Admin" - -@admin_tools.tool -def regular_action() -> str: - return "Regular" - -# Disable at provider level -admin_tools.disable(tags={"admin"}) - -# Server can override if needed -mcp = FastMCP("Server", providers=[admin_tools]) -mcp.enable(names={"admin_action"}) # Re-enables despite provider disable -``` - -Provider-level transforms are useful for setting default visibility that servers can selectively override. - -### Layered Transforms - -Provider transforms run first, then server transforms. Later transforms override earlier ones, so the server has final say. - -```python -from fastmcp import FastMCP -from fastmcp.server.providers import LocalProvider - -provider = LocalProvider() - -@provider.tool(tags={"feature", "beta"}) -def new_feature() -> str: - return "New" - -# Provider enables feature-tagged -provider.enable(tags={"feature"}, only=True) - -# Server disables beta-tagged (runs after provider) -mcp = FastMCP("Server", providers=[provider]) -mcp.disable(tags={"beta"}) - -# new_feature is disabled (server's later disable overrides provider's enable) -``` - -## Per-Session Visibility - -Server-level visibility changes affect all connected clients simultaneously. When you need different clients to see different components, use per-session visibility instead. - -Session visibility lets individual sessions customize their view of available components. When a tool calls `ctx.enable_components()` or `ctx.disable_components()`, those rules apply only to the current session. Other sessions continue to see the global defaults. This enables patterns like progressive disclosure, role-based access, and on-demand feature activation. - -```python -from fastmcp import FastMCP -from fastmcp.server.context import Context - -mcp = FastMCP("Session-Aware Server") - -@mcp.tool(tags={"premium"}) -def premium_analysis(data: str) -> str: - """Advanced analysis available to premium users.""" - return f"Premium analysis of: {data}" - -@mcp.tool -async def unlock_premium(ctx: Context) -> str: - """Unlock premium features for this session.""" - await ctx.enable_components(tags={"premium"}) - return "Premium features unlocked" - -@mcp.tool -async def reset_features(ctx: Context) -> str: - """Reset to default feature set.""" - await ctx.reset_visibility() - return "Features reset to defaults" - -# Premium tools are disabled globally by default -mcp.disable(tags={"premium"}) -``` - -All sessions start with `premium_analysis` hidden. When a session calls `unlock_premium`, that session gains access to premium tools while other sessions remain unaffected. Calling `reset_features` returns the session to the global defaults. - -### How Session Rules Work - -Session rules override global transforms. When listing components, FastMCP first applies global enable/disable rules, then applies session-specific rules on top. Rules within a session accumulate, and later rules override earlier ones for the same component. - -```python -@mcp.tool -async def customize_session(ctx: Context) -> str: - # Enable finance tools for this session - await ctx.enable_components(tags={"finance"}) - - # Also enable admin tools - await ctx.enable_components(tags={"admin"}) - - # Later: disable a specific admin tool - await ctx.disable_components(names={"dangerous_admin_tool"}) - - return "Session customized" -``` - -Each call adds a rule to the session. The `dangerous_admin_tool` ends up disabled because its disable rule was added after the admin enable rule. - -### Filter Criteria - -The session visibility methods accept the same filter criteria as `server.enable()` and `server.disable()`: - -| Parameter | Description | -|-----------|-------------| -| `names` | Component names or URIs to match | -| `keys` | Component keys (e.g., `{"tool:my_tool"}`) | -| `tags` | Tags to match (component must have at least one) | -| `version` | Version specification to match | -| `components` | Component types (`{"tool"}`, `{"resource"}`, `{"prompt"}`, `{"template"}`) | -| `match_all` | If `True`, matches all components regardless of other criteria | - -```python -from fastmcp.utilities.versions import VersionSpec - -@mcp.tool -async def enable_recent_tools(ctx: Context) -> str: - """Enable only tools from version 2.0.0 or later.""" - await ctx.enable_components( - version=VersionSpec(gte="2.0.0"), - components={"tool"} - ) - return "Recent tools enabled" -``` - -### Automatic Notifications - -When session visibility changes, FastMCP automatically sends notifications to that session. Clients receive `ToolListChangedNotification`, `ResourceListChangedNotification`, and `PromptListChangedNotification` so they can refresh their component lists. These notifications go only to the affected session. - -When you specify the `components` parameter, FastMCP optimizes by sending only the relevant notifications: - -```python -# Only sends ToolListChangedNotification -await ctx.enable_components(tags={"finance"}, components={"tool"}) - -# Sends all three notifications (no components filter) -await ctx.enable_components(tags={"finance"}) -``` - -### Namespace Activation Pattern - -A common pattern organizes tools into namespaces using tag prefixes, disables them globally, then provides activation tools that unlock namespaces on demand: - -```python -from fastmcp import FastMCP -from fastmcp.server.context import Context - -server = FastMCP("Multi-Domain Assistant") - -# Finance namespace -@server.tool(tags={"namespace:finance"}) -def analyze_portfolio(symbols: list[str]) -> str: - return f"Analysis for: {', '.join(symbols)}" - -@server.tool(tags={"namespace:finance"}) -def get_market_data(symbol: str) -> dict: - return {"symbol": symbol, "price": 150.25} - -# Admin namespace -@server.tool(tags={"namespace:admin"}) -def list_users() -> list[str]: - return ["alice", "bob", "charlie"] - -# Activation tools - always visible -@server.tool -async def activate_finance(ctx: Context) -> str: - await ctx.enable_components(tags={"namespace:finance"}) - return "Finance tools activated" - -@server.tool -async def activate_admin(ctx: Context) -> str: - await ctx.enable_components(tags={"namespace:admin"}) - return "Admin tools activated" - -@server.tool -async def deactivate_all(ctx: Context) -> str: - await ctx.reset_visibility() - return "All namespaces deactivated" - -# Disable namespace tools globally -server.disable(tags={"namespace:finance", "namespace:admin"}) -``` - -Sessions start seeing only the activation tools. Calling `activate_finance` reveals finance tools for that session only. Multiple namespaces can be activated independently, and `deactivate_all` returns to the initial state. - -### Method Reference - -- **`await ctx.enable_components(...) -> None`**: Enable matching components for this session -- **`await ctx.disable_components(...) -> None`**: Disable matching components for this session -- **`await ctx.reset_visibility() -> None`**: Clear all session rules, returning to global defaults - -## Client Notifications - -When visibility state changes, FastMCP automatically notifies connected clients. Clients supporting the MCP notification protocol receive `list_changed` events and can refresh their component lists. - -This happens automatically. You don't need to trigger notifications manually. - -```python -# This automatically notifies clients -mcp.disable(tags={"maintenance"}) - -# Clients receive: tools/list_changed, resources/list_changed, etc. -``` - -## Filtering Logic - -Understanding the filtering logic helps when debugging visibility state issues. - -The `is_enabled()` function checks a component's internal metadata: - -1. If the component has `meta.fastmcp._internal.visibility = False`, it's disabled -2. If the component has `meta.fastmcp._internal.visibility = True`, it's enabled -3. If no visibility state is set, the component is enabled by default - -When multiple `enable()` and `disable()` calls are made, transforms are applied in order. **Later transforms override earlier ones**, so the last matching transform wins. - -## The Visibility Transform - -Under the hood, `enable()` and `disable()` add `Visibility` transforms to the server or provider. The `Visibility` transform marks components with visibility metadata, and the server applies the final filter after all provider and server transforms complete. - -```python -from fastmcp import FastMCP -from fastmcp.server.transforms import Visibility - -mcp = FastMCP("Server") - -# Using the convenience method (recommended) -mcp.disable(names={"secret_tool"}) - -# Equivalent to: -mcp.add_transform(Visibility(False, names={"secret_tool"})) -``` - -Server-level transforms override provider-level transforms. If a component is disabled at the provider level but enabled at the server level, the server-level `enable()` can re-enable it. diff --git a/docs/v3/tutorials/create-mcp-server.mdx b/docs/v3/tutorials/create-mcp-server.mdx deleted file mode 100644 index de1000703..000000000 --- a/docs/v3/tutorials/create-mcp-server.mdx +++ /dev/null @@ -1,198 +0,0 @@ ---- -title: "How to Create an MCP Server in Python" -sidebarTitle: "Creating an MCP Server" -description: "A step-by-step guide to building a Model Context Protocol (MCP) server using Python and FastMCP, from basic tools to dynamic resources." -icon: server ---- - -So you want to build a Model Context Protocol (MCP) server in Python. The goal is to create a service that can provide tools and data to AI models like Claude, Gemini, or others that support the protocol. While the [MCP specification](https://modelcontextprotocol.io/specification/) is powerful, implementing it from scratch involves a lot of boilerplate: handling JSON-RPC, managing session state, and correctly formatting requests and responses. - -This is where **FastMCP** comes in. It's a high-level framework that handles all the protocol complexities for you, letting you focus on what matters: writing the Python functions that power your server. - -This guide will walk you through creating a fully-featured MCP server from scratch using FastMCP. - -<Tip> -Every code block in this tutorial is a complete, runnable example. You can copy and paste it into a file and run it, or paste it directly into a Python REPL like IPython to try it out. -</Tip> - -### Prerequisites - -Make sure you have FastMCP installed. If not, follow the [installation guide](/getting-started/installation). - -```bash -pip install fastmcp -``` - - -## Step 1: Create the Basic Server - -Every FastMCP application starts with an instance of the `FastMCP` class. This object acts as the container for all your tools and resources. - -Create a new file called `my_mcp_server.py`: - -```python my_mcp_server.py -from fastmcp import FastMCP - -# Create a server instance with a descriptive name -mcp = FastMCP(name="My First MCP Server") -``` - -That's it! You have a valid (though empty) MCP server. Now, let's add some functionality. - -## Step 2: Add a Tool - -Tools are functions that an LLM can execute. Let's create a simple tool that adds two numbers. - -To do this, simply write a standard Python function and decorate it with `@mcp.tool`. - -```python my_mcp_server.py {5-8} -from fastmcp import FastMCP - -mcp = FastMCP(name="My First MCP Server") - -@mcp.tool -def add(a: int, b: int) -> int: - """Adds two integer numbers together.""" - return a + b -``` - -FastMCP automatically handles the rest: -- **Tool Name:** It uses the function name (`add`) as the tool's name. -- **Description:** It uses the function's docstring as the tool's description for the LLM. -- **Schema:** It inspects the type hints (`a: int`, `b: int`) to generate a JSON schema for the inputs. - -This is the core philosophy of FastMCP: **write Python, not protocol boilerplate.** - -## Step 3: Expose Data with Resources - -Resources provide read-only data to the LLM. You can define a resource by decorating a function with `@mcp.resource`, providing a unique URI. - -Let's expose a simple configuration dictionary as a resource. - -```python my_mcp_server.py {10-13} -from fastmcp import FastMCP - -mcp = FastMCP(name="My First MCP Server") - -@mcp.tool -def add(a: int, b: int) -> int: - """Adds two integer numbers together.""" - return a + b - -@mcp.resource("resource://config") -def get_config() -> dict: - """Provides the application's configuration.""" - return {"version": "1.0", "author": "MyTeam"} -``` - -When a client requests the URI `resource://config`, FastMCP will execute the `get_config` function and return its output (serialized as JSON) to the client. The function is only called when the resource is requested, enabling lazy-loading of data. - -## Step 4: Generate Dynamic Content with Resource Templates - -Sometimes, you need to generate resources based on parameters. This is what **Resource Templates** are for. You define them using the same `@mcp.resource` decorator but with placeholders in the URI. - -Let's create a template that provides a personalized greeting. - -```python my_mcp_server.py {15-17} -from fastmcp import FastMCP - -mcp = FastMCP(name="My First MCP Server") - -@mcp.tool -def add(a: int, b: int) -> int: - """Adds two integer numbers together.""" - return a + b - -@mcp.resource("resource://config") -def get_config() -> dict: - """Provides the application's configuration.""" - return {"version": "1.0", "author": "MyTeam"} - -@mcp.resource("greetings://{name}") -def personalized_greeting(name: str) -> str: - """Generates a personalized greeting for the given name.""" - return f"Hello, {name}! Welcome to the MCP server." -``` - -Now, clients can request dynamic URIs: -- `greetings://Ford` will call `personalized_greeting(name="Ford")`. -- `greetings://Marvin` will call `personalized_greeting(name="Marvin")`. - -FastMCP automatically maps the `{name}` placeholder in the URI to the `name` parameter in your function. - -## Step 5: Run the Server - -To make your server executable, add a `__main__` block to your script that calls `mcp.run()`. - -```python my_mcp_server.py {19-20} -from fastmcp import FastMCP - -mcp = FastMCP(name="My First MCP Server") - -@mcp.tool -def add(a: int, b: int) -> int: - """Adds two integer numbers together.""" - return a + b - -@mcp.resource("resource://config") -def get_config() -> dict: - """Provides the application's configuration.""" - return {"version": "1.0", "author": "MyTeam"} - -@mcp.resource("greetings://{name}") -def personalized_greeting(name: str) -> str: - """Generates a personalized greeting for the given name.""" - return f"Hello, {name}! Welcome to the MCP server." - -if __name__ == "__main__": - mcp.run() -``` - -Now you can run your server from the command line: -```bash -python my_mcp_server.py -``` -This starts the server using the default **STDIO transport**, which is how clients like Claude Desktop communicate with local servers. To learn about other transports, like HTTP, see the [Running Your Server](/deployment/running-server) guide. - -## The Complete Server - -Here is the full code for `my_mcp_server.py` (click to expand): - -```python my_mcp_server.py [expandable] -from fastmcp import FastMCP - -# 1. Create the server -mcp = FastMCP(name="My First MCP Server") - -# 2. Add a tool -@mcp.tool -def add(a: int, b: int) -> int: - """Adds two integer numbers together.""" - return a + b - -# 3. Add a static resource -@mcp.resource("resource://config") -def get_config() -> dict: - """Provides the application's configuration.""" - return {"version": "1.0", "author": "MyTeam"} - -# 4. Add a resource template for dynamic content -@mcp.resource("greetings://{name}") -def personalized_greeting(name: str) -> str: - """Generates a personalized greeting for the given name.""" - return f"Hello, {name}! Welcome to the MCP server." - -# 5. Make the server runnable -if __name__ == "__main__": - mcp.run() -``` - -## Next Steps - -You've successfully built an MCP server! From here, you can explore more advanced topics: - -- [**Tools in Depth**](/servers/tools): Learn about asynchronous tools, error handling, and custom return types. -- [**Resources & Templates**](/servers/resources): Discover different resource types, including files and HTTP endpoints. -- [**Prompts**](/servers/prompts): Create reusable prompt templates for your LLM. -- [**Running Your Server**](/deployment/running-server): Deploy your server with different transports like HTTP. - diff --git a/docs/v3/tutorials/mcp.mdx b/docs/v3/tutorials/mcp.mdx deleted file mode 100644 index fd3995fff..000000000 --- a/docs/v3/tutorials/mcp.mdx +++ /dev/null @@ -1,120 +0,0 @@ ---- -title: "What is the Model Context Protocol (MCP)?" -sidebarTitle: "What is MCP?" -description: "An introduction to the core concepts of the Model Context Protocol (MCP), explaining what it is, why it's useful, and how it works." -icon: "diagram-project" ---- - -The Model Context Protocol (MCP) is an open standard designed to solve a fundamental problem in AI development: how can Large Language Models (LLMs) reliably and securely interact with external tools, data, and services? - -It's the **bridge between the probabilistic, non-deterministic world of AI and the deterministic, reliable world of your code and data.** - -While you could build a custom REST API for your LLM, MCP provides a specialized, standardized "port" for AI-native communication. Think of it as **USB-C for AI**: a single, well-defined interface for connecting any compliant LLM to any compliant tool or data source. - -This guide provides a high-level overview of the protocol itself. We'll use **FastMCP**, the leading Python framework for MCP, to illustrate the concepts with simple code examples. - -## Why Do We Need a Protocol? - -With countless APIs already in existence, the most common question is: "Why do we need another one?" - -The answer lies in **standardization**. The AI ecosystem is fragmented. Every model provider has its own way of defining and calling tools. MCP's goal is to create a common language that offers several key advantages: - -1. **Interoperability:** Build one MCP server, and it can be used by any MCP-compliant client (Claude, Gemini, OpenAI, custom agents, etc.) without custom integration code. This is the protocol's most important promise. -2. **Discoverability:** Clients can dynamically ask a server what it's capable of at runtime. They receive a structured, machine-readable "menu" of tools and resources. -3. **Security & Safety:** MCP provides a clear, sandboxed boundary. An LLM can't execute arbitrary code on your server; it can only *request* to run the specific, typed, and validated functions you explicitly expose. -4. **Composability:** You can build small, specialized MCP servers and combine them to create powerful, complex applications. - -## Core MCP Components - -An MCP server exposes its capabilities through three primary components: Tools, Resources, and Prompts. - -### Tools: Executable Actions - -Tools are functions that the LLM can ask the server to execute. They are the action-oriented part of MCP. - -In the spirit of a REST API, you can think of **Tools as being like `POST` requests.** They are used to *perform an action*, *change state*, or *trigger a side effect*, like sending an email, adding a user to a database, or making a calculation. - -With FastMCP, creating a tool is as simple as decorating a Python function. - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -# This function is now an MCP tool named "get_weather" -@mcp.tool -def get_weather(city: str) -> dict: - """Gets the current weather for a specific city.""" - # In a real app, this would call a weather API - return {"city": city, "temperature": "72F", "forecast": "Sunny"} -``` - -[**Learn more about Tools →**](/servers/tools) - -### Resources: Read-Only Data - -Resources are data sources that the LLM can read. They are used to load information into the LLM's context, providing it with knowledge it doesn't have from its training data. - -Following the REST API analogy, **Resources are like `GET` requests.** Their purpose is to *retrieve information* idempotently, ideally without causing side effects. A resource can be anything from a static text file to a dynamic piece of data from a database. Each resource is identified by a unique URI. - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -# This function provides a resource at the URI "system://status" -@mcp.resource("system://status") -def get_system_status() -> dict: - """Returns the current operational status of the service.""" - return {"status": "all systems normal"} -``` - -#### Resource Templates - -You can also create **Resource Templates** for dynamic data. A client could request `users://42/profile` to get the profile for a specific user. - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -# This template provides user data for any given user ID -@mcp.resource("users://{user_id}/profile") -def get_user_profile(user_id: str) -> dict: - """Returns the profile for a specific user.""" - # Fetch user from a database... - return {"id": user_id, "name": "Zaphod Beeblebrox"} -``` - -[**Learn more about Resources & Templates →**](/servers/resources) - -### Prompts: Reusable Instructions - -Prompts are reusable, parameterized message templates. They provide a way to define consistent, structured instructions that a client can request to guide the LLM's behavior for a specific task. - -```python -from fastmcp import FastMCP - -mcp = FastMCP() - -@mcp.prompt -def summarize_text(text_to_summarize: str) -> str: - """Creates a prompt asking the LLM to summarize a piece of text.""" - return f""" - Please provide a concise, one-paragraph summary of the following text: - - {text_to_summarize} - """ -``` - -[**Learn more about Prompts →**](/servers/prompts) - -## Advanced Capabilities - -Beyond the core components, MCP also supports more advanced interaction patterns, such as a server requesting that the *client's* LLM generate a completion (known as **sampling**), or a server sending asynchronous **notifications** to a client. These features enable more complex, bidirectional workflows and are fully supported by FastMCP. - -## Next Steps - -Now that you understand the core concepts of the Model Context Protocol, you're ready to start building. The best place to begin is our step-by-step tutorial. - -[**Tutorial: How to Create an MCP Server in Python →**](/tutorials/create-mcp-server) diff --git a/docs/v3/tutorials/rest-api.mdx b/docs/v3/tutorials/rest-api.mdx deleted file mode 100644 index 90872c950..000000000 --- a/docs/v3/tutorials/rest-api.mdx +++ /dev/null @@ -1,203 +0,0 @@ ---- -title: "How to Connect an LLM to a REST API" -sidebarTitle: "Connect LLMs to REST APIs" -description: "A step-by-step guide to making any REST API with an OpenAPI spec available to LLMs using FastMCP." -icon: "plug" ---- - -You've built a powerful REST API, and now you want your LLM to be able to use it. Manually writing a wrapper function for every single endpoint is tedious, error-prone, and hard to maintain. - -This is where **FastMCP** shines. If your API has an OpenAPI (or Swagger) specification, FastMCP can automatically convert your entire API into a fully-featured MCP server, making every endpoint available as a secure, typed tool for your AI model. - -This guide will walk you through converting a public REST API into an MCP server in just a few lines of code. - -<Tip> -Every code block in this tutorial is a complete, runnable example. You can copy and paste it into a file and run it, or paste it directly into a Python REPL like IPython to try it out. -</Tip> - -### Prerequisites - -Make sure you have FastMCP installed. If not, follow the [installation guide](/getting-started/installation). - -```bash -pip install fastmcp -``` - -## Step 1: Choose a Target API - -For this tutorial, we'll use the [JSONPlaceholder API](https://jsonplaceholder.typicode.com/), a free, fake online REST API for testing and prototyping. It's perfect because it's simple and has a public OpenAPI specification. - -- **API Base URL:** `https://jsonplaceholder.typicode.com` -- **OpenAPI Spec URL:** We'll use a community-provided spec for it. - -## Step 2: Create the MCP Server - -Now for the magic. We'll use `FastMCP.from_openapi`. This method takes an `httpx.AsyncClient` configured for your API and its OpenAPI specification, and automatically converts **every endpoint** into a callable MCP `Tool`. - -<Tip> -Learn more about working with OpenAPI specs in the [OpenAPI integration docs](/integrations/openapi). -</Tip> - -<Note> -For this tutorial, we'll use a simplified OpenAPI spec directly in the code. In a real project, you would typically load the spec from a URL or local file. -</Note> - -Create a file named `api_server.py`: - -```python api_server.py {31-35} -import httpx -from fastmcp import FastMCP - -# Create an HTTP client for the target API -client = httpx.AsyncClient(base_url="https://jsonplaceholder.typicode.com") - -# Define a simplified OpenAPI spec for JSONPlaceholder -openapi_spec = { - "openapi": "3.0.0", - "info": {"title": "JSONPlaceholder API", "version": "1.0"}, - "paths": { - "/users": { - "get": { - "summary": "Get all users", - "operationId": "get_users", - "responses": {"200": {"description": "A list of users."}} - } - }, - "/users/{id}": { - "get": { - "summary": "Get a user by ID", - "operationId": "get_user_by_id", - "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], - "responses": {"200": {"description": "A single user."}} - } - } - } -} - -# Create the MCP server from the OpenAPI spec -mcp = FastMCP.from_openapi( - openapi_spec=openapi_spec, - client=client, - name="JSONPlaceholder MCP Server" -) - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` - -And that's it! With just a few lines of code, you've created an MCP server that exposes the entire JSONPlaceholder API as a collection of tools. - -## Step 3: Test the Generated Server - -Let's verify that our new MCP server works. We can use the `fastmcp.Client` to connect to it and inspect its tools. - -<Tip> -Learn more about the FastMCP client in the [client docs](/clients/client). -</Tip> - -Create a separate file, `api_client.py`: - -```python api_client.py {2, 6, 9, 16} -import asyncio -from fastmcp import Client - -async def main(): - # Connect to the MCP server we just created - async with Client("http://127.0.0.1:8000/mcp") as client: - - # List the tools that were automatically generated - tools = await client.list_tools() - print("Generated Tools:") - for tool in tools: - print(f"- {tool.name}") - - # Call one of the generated tools - print("\n\nCalling tool 'get_user_by_id'...") - user = await client.call_tool("get_user_by_id", {"id": 1}) - print(f"Result:\n{user.data}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -First, run your server: -```bash -python api_server.py -``` - -Then, in another terminal, run the client: -```bash -python api_client.py -``` - -You should see a list of generated tools (`get_users`, `get_user_by_id`) and the result of calling the `get_user_by_id` tool, which fetches data from the live JSONPlaceholder API. - -![](/assets/images/tutorial-rest-api-result.png) - - -## Step 4: Customizing Route Maps - -By default, FastMCP converts every API endpoint into an MCP `Tool`. This ensures maximum compatibility with contemporary LLM clients, many of which **only support the `tools` part of the MCP specification.** - -However, for clients that support the full MCP spec, representing `GET` requests as `Resources` can be more semantically correct and efficient. - -FastMCP allows users to customize this behavior using the concept of "route maps". A `RouteMap` is a mapping of an API route to an MCP type. FastMCP checks each API route against your custom maps in order. If a route matches a map, it's converted to the specified `mcp_type`. Any route that doesn't match your custom maps will fall back to the default behavior (becoming a `Tool`). - -<Tip> -Learn more about route maps in the [OpenAPI integration docs](/integrations/openapi#route-mapping). -</Tip> - -Here’s how you can add custom route maps to turn `GET` requests into `Resources` and `ResourceTemplates` (if they have path parameters): - -```python api_server_with_resources.py {3, 37-42} -import httpx -from fastmcp import FastMCP -from fastmcp.server.providers.openapi import RouteMap, MCPType - - -# Create an HTTP client for the target API -client = httpx.AsyncClient(base_url="https://jsonplaceholder.typicode.com") - -# Define a simplified OpenAPI spec for JSONPlaceholder -openapi_spec = { - "openapi": "3.0.0", - "info": {"title": "JSONPlaceholder API", "version": "1.0"}, - "paths": { - "/users": { - "get": { - "summary": "Get all users", - "operationId": "get_users", - "responses": {"200": {"description": "A list of users."}} - } - }, - "/users/{id}": { - "get": { - "summary": "Get a user by ID", - "operationId": "get_user_by_id", - "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}], - "responses": {"200": {"description": "A single user."}} - } - } - } -} - -# Create the MCP server with custom route mapping -mcp = FastMCP.from_openapi( - openapi_spec=openapi_spec, - client=client, - name="JSONPlaceholder MCP Server", - route_maps=[ - # Map GET requests with path parameters (e.g., /users/{id}) to ResourceTemplate - RouteMap(methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE), - # Map all other GET requests to Resource - RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE), - ] -) - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) -``` -With this configuration: -- `GET /users/{id}` becomes a `ResourceTemplate`. -- `GET /users` becomes a `Resource`. -- Any `POST`, `PUT`, etc. endpoints would still become `Tools` by default. \ No newline at end of file diff --git a/docs/v3/updates.mdx b/docs/v3/updates.mdx deleted file mode 100644 index 0faf12da9..000000000 --- a/docs/v3/updates.mdx +++ /dev/null @@ -1,743 +0,0 @@ ---- -title: "FastMCP Updates" -sidebarTitle: "Updates" -icon: "sparkles" -tag: NEW ---- - -<Update label="FastMCP 3.4.4" description="July 8, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.4.4: Host in Translation" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.4" -cta="Read the release notes" -> -A compatibility patch for HTTP deployments affected by the 3.4.3 Host/Origin guard defaults. FastMCP 3.x now keeps strict Host and Origin validation available for explicit opt-in deployments without rejecting existing ASGI, serverless, and reverse-proxy traffic by default. - -🌐 **HTTP compatibility restored** — existing hosted deployments keep accepting their public Host headers unless strict host/origin protection is configured. - -🔐 **Guard remains available** — deployments that know their public host and browser origins can still enable strict validation with `host_origin_protection=True`, `allowed_hosts`, and `allowed_origins`. - -🤗 **Hugging Face auth** — new OAuth provider support covers public and private Hugging Face apps, with docs and examples for PKCE, Dynamic Client Registration, and CIMD. -</Card> -</Update> - -<Update label="FastMCP 3.4.3" description="July 5, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.4.3: The Fast and the Secure-ious" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.3" -cta="Read the release notes" -> -A month of SSRF and OAuth hardening lands in one patch. NAT64, 6to4, Teredo, and ISATAP transition addresses can no longer smuggle private IPv4 targets past the SSRF allow-list, Streamable HTTP validates Host and Origin before session handling to block DNS rebinding, and OAuth redirect validation rejects unsafe schemes and unregistered DCR redirect URIs. - -🛡️ **SSRF allow-list hardening** — every IPv6 transition form (NAT64, 6to4, Teredo, ISATAP) now unwraps to its embedded IPv4 target and gets checked against the same policy. - -🌐 **DNS rebinding protection** — Streamable HTTP validates `Host` and browser `Origin` before session handling, closing a path to localhost-bound unauthenticated servers. - -🔐 **Stricter OAuth redirects** — unsafe redirect schemes are rejected before registration, and DCR clients are bound to the redirect URIs they registered. - -🧵 **Reliability fixes** — proxy session teardown races, discriminator-tag handling in JSON schema conversion, and several smaller fixes across middleware and resource templates. -</Card> -</Update> - -<Update label="FastMCP 3.4.2" description="June 6, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.4.2: Heads Up" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.2" -cta="Read the release notes" -> -A compatibility patch. `JWTVerifier` now accepts JWTs carrying private, non-critical JWS header parameters (like Clerk's `cat`) instead of rejecting them before signature and claim validation, while unsupported critical headers are still rejected. -</Card> -</Update> - -<Update label="FastMCP 3.4.1" description="June 5, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.4.1: Floor It" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.1" -cta="Read the release notes" -> -A security patch. FastMCP now floors Starlette at `>=1.0.1`, so installs can no longer resolve to a version affected by CVE-2026-48710 — previously the dependency was only constrained transitively through `mcp`. OAuthProxy also logs refresh-token cache misses instead of failing silently. -</Card> -</Update> - -<Update label="FastMCP 3.4.0" description="June 2, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.4.0: Remote Control" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.0" -cta="Read the release notes" -> -The remote release. `fastmcp-remote` is a standalone bridge that connects stdio-only MCP hosts to servers hosted over HTTP, with OAuth enabled automatically for HTTPS. The proxy layer underneath it is hardened so bridges fail loudly on a missing or misconfigured upstream, and FastMCP-issued tokens can now outlive short-lived upstream tokens to survive long idle periods. - -🌉 **fastmcp-remote** — `uvx fastmcp-remote https://example.com/mcp` bridges a remote server back to a stdio-only host. - -🔌 **Bridges fail loudly** — proxies forward `initialize` upstream, so a missing backend or wrong URL fails the handshake instead of returning an empty-but-connected proxy. - -🔐 **Longer-lived tokens** — `fastmcp_access_token_expiry_seconds` decouples the client-facing token lifetime from a short upstream `expires_in`. - -⚠️ **Returnable tool errors** — `ToolResult(..., is_error=True)` hands back rich errors the model can act on instead of only raising. -</Card> -</Update> - -<Update label="FastMCP 3.3.1" description="May 15, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.3.1: Loop There It Is" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.3.1" -cta="Read the release notes" -> -Hotfix for the 3.3 packaging split: standalone component imports like `from fastmcp.tools import tool` no longer pull in the server stack or trip a circular import. Component-level auth and task primitives moved to lightweight utility modules, with the old import paths preserved as compatibility re-exports. -</Card> -</Update> - -<Update label="FastMCP 3.3.0" description="May 15, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.3.0: Slim Reaper" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.3.0" -cta="Read the release notes" -> -The `fastmcp-slim` release. A dependency-light distribution that ships FastMCP's client and transport layer without Starlette, Uvicorn, or the server stack — the import namespace is unchanged. It also closes out a backlog of OAuth proxy hardening, MCP-compliant OTEL instrumentation, and auth additions. - -🪶 **fastmcp-slim** — install the client without the server footprint for CI, agents, and library dependencies. - -🔐 **OAuth proxy hardening** — silent-consent AS-in-the-middle guard, dot-segment redirect rejection, and per-token response cache partitioning. - -🔑 **Auth additions** — `AzureB2CProvider` user flows and a public `update_scopes()` API on `OAuthProxy`. - -🧵 **Thread affinity** — `@mcp.tool(run_in_thread=False)` for tools bound to a specific thread. -</Card> -</Update> - -<Update label="FastMCP 3.2.4" description="April 14, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.2.4: Patch Me If You Can" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.4" -cta="Read the release notes" -> -A grab bag of fixes and hardening. Background tasks are now scoped to the authorization context instead of the MCP session — a breaking change for anyone relying on session-scoped semantics — and parameter descriptions are extracted from docstrings automatically. - -🔐 **Security** — `FileUpload` validates decoded base64 size, the proxy stops forwarding inbound headers to unrelated servers, and AuthKit binds token audience per RFC 8707. - -🔑 **Keycloak** — new OAuth provider for enterprise auth and local dev. -</Card> -</Update> - -<Update label="FastMCP 3.2.3" description="April 9, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.2.3: Redis or Not" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.3" -cta="Read the release notes" -> -Pins `fakeredis<2.35.0` in the `tasks` extra: a 2.35.0 rename broke pydocket's `memory://` backend and made `fastmcp[tasks]` installs fail at startup with an `ImportError`. -</Card> -</Update> - -<Update label="FastMCP 3.2.2" description="April 9, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.2.2: Audience Appreciation" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.2" -cta="Read the release notes" -> -Fixes the Azure audience regression from 3.2.1 — both the bare client ID GUID and a custom `identifier_uri` are now accepted as the token audience. -</Card> -</Update> - -<Update label="FastMCP 3.2.1" description="April 8, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.2.1: Audience Participation" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.1" -cta="Read the release notes" -> -A patch focused on auth-provider audience validation: Cognito validates on `client_id`, Azure honors `identifier_uri`, and consent cookies are LRU-capped to avoid header overflow. Also fixes OpenAPI 3.0 `nullable` fields leaking into tool input schemas and server-variable substitution in base URLs. -</Card> -</Update> - -<Update label="FastMCP 3.2.0" description="March 30, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.2.0: Show Don't Tool" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.2.0" -cta="Read the release notes" -> -The Apps release. Your tools can return interactive UIs — charts, dashboards, forms, maps — rendered right inside the conversation. - -🎨 **FastMCPApp** — separate the tools the LLM sees (`@app.ui()`) from the backend tools the UI calls (`@app.tool()`), built on Prefab. - -🧩 **Built-in providers** — FileUpload, Approval, Choice, FormInput, and GenerativeUI. - -🖥️ **Dev server** — `fastmcp dev apps` previews app tools in the browser with an MCP message inspector. - -🔒 **Security pass** — SSRF/path-traversal prevention, JWT algorithm restrictions, OAuth scope enforcement, and CSRF fixes. -</Card> -</Update> - -<Update label="FastMCP 3.1.1" description="March 14, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.1.1: 'Tis But a Patch" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.1" -cta="Read the release notes" -> -Pins `pydantic-monty<0.0.8` to fix a breaking change in Monty that affects code mode. -</Card> -</Update> - -<Update label="FastMCP 3.1.0" description="March 3, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.1.0: Code to Joy" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.1.0" -cta="Read the release notes" -> -The Code Mode release. Instead of loading the entire tool catalog into context, `CodeMode` gives LLMs meta-tools: search for relevant tools on demand, inspect their schemas, then write Python that chains `call_tool()` calls in a sandbox. Also ships search transforms, early Prefab Apps integration, `MultiAuth` for composing multiple token verification sources, and PropelAuth support. -</Card> -</Update> - -<Update label="FastMCP 3.0.2" description="February 22, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.0.2: Threecovery Mode II" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.2" -cta="Read the release notes" -> -Two community-contributed fixes: auth headers from MCP transport no longer leak through to downstream OpenAPI APIs, and background task workers now correctly receive the originating request ID. Plus a new docs example for context-aware tool factories. -</Card> -</Update> - -<Update label="FastMCP 3.0.1" description="February 20, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.0.1: Three-covery Mode" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.1" -cta="Read the release notes" -> -First patch after 3.0 — mostly smoothing out rough edges discovered in the wild. The big ones: middleware state that wasn't surviving the trip to tool handlers now does, `Tool.from_tool()` accepts callables again, OpenAPI schemas with circular references no longer crash discovery, and decorator overloads now return the correct types in function mode. - -🔐 **OIDC `verify_id_token`** — New option for providers that issue opaque access tokens but standard JWT id_tokens. Verifies identity via the id_token while using the access_token for upstream API calls. - -🐞 **11 bug fixes** — State serialization, future annotations with `Context`/`Depends`, OpenAI handler deprecation warnings, type checker compatibility, and more. -</Card> -</Update> - -<Update label="FastMCP 3.0.0" description="February 18, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.0.0: Three at Last" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0" -cta="Read the release notes" -img="assets/updates/release-3-0.png" -> -FastMCP 3.0 is stable. Two betas, two release candidates, 21 new contributors, and more than 100,000 pre-release installs later — the architecture held up, the upgrade path was smooth, and we're shipping it. - -The surface API is largely unchanged — `@mcp.tool()` still works exactly as before. What changed is everything underneath: a provider/transform architecture that makes FastMCP extensible, observable, and composable in ways v2 couldn't support. - -🔌 **Build servers from anything** — `FileSystemProvider`, `OpenAPIProvider`, `ProxyProvider`, `SkillsProvider`, and composable transforms that rename, namespace, filter, version, and secure components as they flow to clients. - -🔐 **Ship to production** — Component versioning, granular authorization with async auth checks, CIMD, Static Client Registration, Azure OBO, OpenTelemetry tracing, and background tasks with distributed Redis notification. - -💾 **Adapt per session** — Session state persists across requests, and `ctx.enable_components()` / `ctx.disable_components()` let servers adapt dynamically per client. - -⚡ **Develop faster** — `--reload`, standalone decorators, automatic threadpool dispatch, tool timeouts, pagination, and concurrent tool execution. - -🖥️ **CLI** — `fastmcp list`, `fastmcp call`, `fastmcp discover`, `fastmcp generate-cli`, and `fastmcp install` for Claude Desktop, Cursor, and Goose. -</Card> -</Update> - -<Update label="FastMCP 3.0.0rc1" description="February 12, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.0.0rc1: RC-ing is Believing" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0rc1" -cta="Read the release notes" -> -FastMCP 3 RC1 means we believe the API is stable. Beta 2 drew a wave of real-world adoption — production deployments, migration reports, integration testing — and the feedback overwhelmingly confirmed that the architecture works. This release closes gaps that surfaced under load: auth flows that needed to be async, background tasks that needed reliable notification delivery, and APIs still carrying beta-era naming. If nothing unexpected surfaces, this is what 3.0.0 looks like. - -🚨 **Breaking Changes** — The `ui=` parameter is now `app=` with a unified `AppConfig` class, and 16 `FastMCP()` constructor kwargs have been removed after months of deprecation warnings. - -🔐 **Auth Improvements** — Async `auth=` checks, Static Client Registration for servers without DCR, and declarative Azure OBO flows via dependency injection. - -⚡ **Concurrent Sampling** — `context.sample()` can now execute multiple tool calls in parallel with `tool_concurrency=0`. - -📡 **Background Task Notifications** — A distributed Redis queue replaces polling for progress updates and elicitation relay. - -✅ **OpenAPI Output Validation** — `validate_output=False` disables strict schema checking for imperfect backend APIs. -</Card> -</Update> - -<Update label="FastMCP 3.0.0b2" description="February 7, 2026" tags={["Releases"]}> -<Card -title="FastMCP v3.0.0b2: 2 Fast 2 Beta" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0b2" -cta="Read the release notes" -> -Beta 2 reflects the huge number of people that kicked the tires on Beta 1. Seven new contributors landed changes, and early migration reports went smoother than expected. Most of Beta 2 is refinement — fixing what people found, filling gaps from real usage, hardening edges — but a few new features landed along the way. - -🖥️ **Client CLI** — `fastmcp list`, `fastmcp call`, `fastmcp discover`, and `fastmcp generate-cli` turn any MCP server into something you can poke at from a terminal. - -🔐 **CIMD** (Client ID Metadata Documents) adds an alternative to Dynamic Client Registration for OAuth. - -📱 **MCP Apps** — Spec-level compliance for the MCP Apps extension with `ui://` resource scheme and typed UI metadata. - -⏳ **Background Task Context** — `Context` now works transparently in Docket workers with Redis-based coordination. - -🛡️ **ResponseLimitingMiddleware** caps tool response sizes with UTF-8-safe truncation. - -🪿 **Goose Integration** — `fastmcp install goose` for one-command server installation into Goose. -</Card> -</Update> - -<Update label="FastMCP 3.0.0b1" description="January 20, 2026" tags={["Releases"]}> -<Card -title="FastMCP 3.0.0b1: This Beta Work" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0b1" -cta="Read the release notes" -> -FastMCP 3.0 rebuilds the framework around three primitives: components, providers, and transforms. Providers source components dynamically—from decorators, filesystems, OpenAPI specs, remote servers, or anywhere else. Transforms modify components as they flow to clients. The features that required specialized subsystems in v2 now compose naturally from these building blocks. - -🔌 **Provider Architecture** unifies how components are sourced with `FileSystemProvider`, `SkillsProvider`, `OpenAPIProvider`, and `ProxyProvider`. - -🔄 **Transforms** add middleware for components—namespace, rename, filter by version, control visibility. - -📋 **Component Versioning** lets you register multiple versions of the same tool with automatic highest-version selection. - -💾 **Session-Scoped State** persists across requests, with per-session visibility control. - -⚡ **DX Improvements** include `--reload` for development, automatic threadpool dispatch, tool timeouts, pagination, and OpenTelemetry tracing. -</Card> -</Update> - -<Update label="FastMCP 2.14.7" description="April 13, 2026" tags={["Releases"]}> -<Card -title="FastMCP 2.14.7: Fake It Till You Break It" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.7" -cta="Read the release notes" -> -A 2.x backport of the fakeredis pin: fakeredis 2.35.0 renamed a connection class that pydocket's `memory://` backend relied on, crashing `fastmcp[tasks]` installs at startup. Caps `fakeredis<2.35.0` on the 2.x line. -</Card> -</Update> - -<Update label="FastMCP 2.14.6" description="March 27, 2026" tags={["Releases"]}> -<Card -title="FastMCP 2.14.6: $Ref Dead Redemption" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.6" -cta="Read the release notes" -> -v2.14.4 backported `dereference_refs()` but never wired it into the tool schema pipeline — `$ref` and `$defs` were still sent to MCP clients. Now fixed: schemas are fully inlined before reaching clients. -</Card> -</Update> - -<Update label="FastMCP 2.14.5" description="February 3, 2026" tags={["Releases"]}> -<Card -title="FastMCP 2.14.5: Sealed Docket" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.5" -cta="Read the release notes" -> -Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2. -</Card> -</Update> - -<Update label="FastMCP 2.14.4" description="January 22, 2026" tags={["Releases"]}> -<Card -title="FastMCP 2.14.4: Package Deal" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.4" -cta="Read the release notes" -> -Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports $ref dereferencing in tool schemas and a task capabilities location fix. -</Card> -</Update> - -<Update label="FastMCP 2.14.3" description="January 12, 2026" tags={["Releases"]}> -<Card -title="FastMCP 2.14.3: Time After Timeout" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.3" -cta="Read the release notes" -> -Sometimes five seconds just isn't enough. This release fixes an HTTP transport bug that was cutting connections short, along with OAuth and Redis fixes, better ASGI support, and CLI update notifications so you never miss a beat. - -⏱️ **HTTP transport timeout fix** restores MCP's 30-second default connect timeout, which was incorrectly defaulting to 5 seconds. - -🔧 **Infrastructure fixes** including OAuth token storage TTL, Redis key prefixing for ACL isolation, and ContextVar propagation for ASGI-mounted servers with background tasks. -</Card> -</Update> - -<Update label="FastMCP 2.14.2" description="December 31, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.14.2: Port Authority" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.2" -cta="Read the release notes" -> -A wave of community contributions arrives safely in the 2.x line. Important backports from 3.0 improve OpenAPI 3.1 compatibility, MCP spec compliance for output schemas and elicitation, and correct a subtle base_url fallback issue. - -🔧 **OpenAPI 3.1 support** fixes version detection to properly handle 3.1 specs alongside 3.0. - -📋 **MCP spec compliance** for root-level `$ref` resolution in output schemas and titled enum elicitation schemas. -</Card> -</Update> - -<Update label="FastMCP 2.14.1" description="December 15, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.14.1: 'Tis a Gift to Be Sample" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.1" -cta="Read the release notes" -> -FastMCP 2.14.1 introduces sampling with tools (SEP-1577), enabling servers to pass tools to `ctx.sample()` for agentic workflows where the LLM can automatically execute tool calls in a loop. - -🤖 **Sampling with tools** lets servers leverage client LLM capabilities for multi-step agentic workflows. The new `ctx.sample_step()` method provides single LLM calls with tool inspection, while `result_type` enables structured outputs via validated Pydantic models. - -🔧 **AnthropicSamplingHandler** joins the existing OpenAI handler, and both are now promoted from experimental to production-ready status with a unified API. -</Card> -</Update> - -<Update label="FastMCP 2.14.0" description="December 11, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.14.0: Task and You Shall Receive" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.0" -cta="Read the release notes" -> -FastMCP 2.14 begins adopting the MCP 2025-11-25 specification, introducing protocol-native background tasks that enable long-running operations to report progress without blocking clients. - -⏳ **Background Tasks (SEP-1686)** let you add `task=True` to any async tool decorator. Powered by [Docket](https://github.com/chrisguidry/docket) for enterprise task scheduling—in-memory backends work out-of-the-box, Redis enables persistence and horizontal scaling. - -🔧 **OpenAPI Parser Promoted** from experimental to standard with improved performance through single-pass schema processing. - -📋 **MCP Spec Updates** including SSE polling (SEP-1699), multi-select elicitation (SEP-1330), and tool name validation (SEP-986). Also removes deprecated APIs accumulated across 2.x. -</Card> -</Update> - -<Update label="FastMCP 2.13.3" description="December 3, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.13.3: Pin-ish Line" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.3" -cta="Read the release notes" -> -Pins `mcp<1.23` as a precaution due to MCP SDK changes related to the 11/25/25 protocol update that break certain FastMCP patches and workarounds. FastMCP 2.14 introduces proper support for the updated protocol. -</Card> -</Update> - -<Update label="FastMCP 2.13.2" description="December 1, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.13.2: Refreshing Changes" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.2" -cta="Read the release notes" -> -Polishes the authentication stack with improvements to token refresh, scope handling, and multi-instance deployments. - -🎮 **Discord OAuth provider** added as a built-in authentication option. - -🔄 **Token refresh fixes** for Azure and Google providers, plus OAuth proxy improvements for multi-instance deployments. - -🎨 **Icon support** added to proxy classes for richer UX. -</Card> -</Update> - -<Update label="FastMCP 2.13.1" description="November 15, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.13.1: Heavy Meta" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.1" -cta="Read the release notes" -> -Introduces meta parameter support for `ToolResult`, enabling tools to return supplementary metadata alongside results for patterns like OpenAI's Apps SDK. - -🏷️ **Meta parameters** let tools return supplementary metadata alongside results. - -🔐 **New auth providers** for OCI and Supabase, plus custom token verifiers with DebugTokenVerifier for development. - -🔒 **Security fixes** for CVE-2025-61920 and safer Cursor deeplink URL validation on Windows. -</Card> -</Update> - -<Update label="FastMCP 2.13.0" description="October 25, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.13.0: Cache Me If You Can" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.0" -cta="Read the release notes" -> -FastMCP 2.13 "Cache Me If You Can" represents a fundamental maturation of the framework. After months of community feedback on authentication and state management, this release delivers the infrastructure FastMCP needs to handle production workloads: persistent storage, response caching, and pragmatic OAuth improvements that reflect real-world deployment challenges. - -💾 **Pluggable storage backends** bring persistent state to FastMCP servers. Built on [py-key-value-aio](https://github.com/strawgate/py-key-value), a new library from FastMCP maintainer Bill Easton ([@strawgate](https://github.com/strawgate)), the storage layer provides encrypted disk storage by default, platform-aware token management, and a simple key-value interface for application state. We're excited to bring this elegantly designed library into the FastMCP ecosystem - it's both powerful and remarkably easy to use, including wrappers to add encryption, TTLs, caching, and more to backends ranging from Elasticsearch, Redis, DynamoDB, filesystem, in-memory, and more! - -🔐 **OAuth maturity** brings months of production learnings into the framework. The new consent screen prevents confused deputy and authorization bypass attacks discovered in earlier versions, while the OAuth proxy now issues its own tokens with automatic key derivation. RFC 7662 token introspection support enables enterprise auth flows, and path prefix mounting enables OAuth-protected servers to integrate into existing web applications. FastMCP now supports out-of-the-box authentication with [WorkOS](https://gofastmcp.com/integrations/workos) and [AuthKit](https://gofastmcp.com/integrations/authkit), [GitHub](https://gofastmcp.com/integrations/github), [Google](https://gofastmcp.com/integrations/google), [Azure](https://gofastmcp.com/integrations/azure) (Entra ID), [AWS Cognito](https://gofastmcp.com/integrations/aws-cognito), [Auth0](https://gofastmcp.com/integrations/auth0), [Descope](https://gofastmcp.com/integrations/descope), [Scalekit](https://gofastmcp.com/integrations/scalekit), [JWTs](https://gofastmcp.com/servers/auth/token-verification#jwt-token-verification), and [RFC 7662 token introspection](https://gofastmcp.com/servers/auth/token-verification#token-introspection-protocol). - -⚡ **Response Caching Middleware** dramatically improves performance for expensive operations, while **Server lifespans** provide proper initialization and cleanup hooks that run once per server instance instead of per client session. - -✨ **Developer experience improvements** include Pydantic input validation, icon support, RFC 6570 query parameters for resource templates, improved Context API methods, and async file/directory resources. -</Card> -</Update> - -<Update label="FastMCP 2.12.5" description="October 17, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.12.5: Safety Pin" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.5" -cta="Read the release notes" -> -Pins MCP SDK version below 1.17 to ensure the `.well-known` payload appears in the expected location when using FastMCP auth providers with composite applications. -</Card> -</Update> - -<Update label="FastMCP 2.12.4" description="September 26, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.12.4: OIDC What You Did There" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.4" -cta="Read the release notes" -> -FastMCP 2.12.4 adds comprehensive OIDC support and expands authentication options with AWS Cognito and Descope providers. The release also includes improvements to logging middleware, URL handling for nested resources, persistent OAuth client registration storage, and various fixes to the experimental OpenAPI parser. - -🔐 **OIDC Configuration** brings native support for OpenID Connect, enabling seamless integration with enterprise identity providers. - -🏢 **Enterprise Authentication** expands with AWS Cognito and Descope providers, broadening the authentication ecosystem. - -🛠️ **Improved Reliability** through enhanced URL handling, persistent OAuth storage, and numerous parser fixes based on community feedback. -</Card> -</Update> - -<Update label="FastMCP 2.12.3" description="September 17, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.12.3: Double Time" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.3" -cta="Read the release notes" -> -FastMCP 2.12.3 focuses on performance and developer experience improvements. This release includes optimized auth provider imports that reduce server startup time, enhanced OIDC authentication flows, and automatic inline snapshot creation for testing. -</Card> -</Update> - -<Update label="FastMCP 2.12.2" description="September 3, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.12.2: Perchance to Stream" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.2" -cta="Read the release notes" -> -Hotfix for streamable-http transport validation in fastmcp.json configuration files, resolving a parsing error when CLI arguments were merged against the configuration spec. -</Card> -</Update> - -<Update label="FastMCP 2.12.1" description="September 3, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.12.1: OAuth to Joy" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.1" -cta="Read the release notes" -> -FastMCP 2.12.1 strengthens OAuth proxy implementation with improved client storage reliability, PKCE forwarding, configurable token endpoint authentication methods, and expanded scope handling based on extensive community testing. -</Card> -</Update> - -<Update label="FastMCP 2.12" description="August 31, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.12: Auth to the Races" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.0" -cta="Read the release notes" -> -FastMCP 2.12 represents one of our most significant releases to date. After extensive testing and iteration with the community, we're shipping major improvements to authentication, configuration, and MCP feature adoption. - -🔐 **OAuth Proxy** bridges the gap for providers that don't support Dynamic Client Registration, enabling authentication with GitHub, Google, WorkOS, and Azure through minimal configuration. - -📋 **Declarative JSON Configuration** introduces `fastmcp.json` as the single source of truth for server settings, making MCP servers as portable and shareable as container images. - -🧠 **Sampling API Fallback** tackles adoption challenges by letting servers generate completions server-side when clients don't support the feature, encouraging innovation while maintaining compatibility. -</Card> -</Update> - -<Update label="FastMCP 2.11" description="August 1, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.11: Auth to a Good Start" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.0" -cta="Read the release notes" -> -FastMCP 2.11 brings enterprise-ready authentication and dramatic performance improvements. - -🔒 **Comprehensive OAuth 2.1 Support** with WorkOS AuthKit integration, Dynamic Client Registration, and support for separate resource and authorization servers. - -⚡ **Experimental OpenAPI Parser** delivers dramatic performance gains through single-pass schema processing and optimized memory usage (enable with environment variable). - -💾 **Enhanced State Management** provides persistent state across tool calls with a simple dictionary interface, improving context handling and type annotations. - -This release emphasizes speed and simplicity while setting the foundation for future enterprise features. -</Card> -</Update> - -<Update label="FastMCP 2.10" description="July 2, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.10: Great Spec-tations" -href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.0" -cta="Read the release notes" -> -FastMCP 2.10 achieves full compliance with the 6/18/2025 MCP specification update, introducing powerful new communication patterns. - -💬 **Elicitation Support** enables dynamic server-client communication and "human-in-the-loop" workflows, allowing servers to request additional information during execution. - -📊 **Output Schemas** provide structured outputs for tools, making results more predictable and easier to parse programmatically. - -🛠️ **Enhanced HTTP Routing** with OpenAPI extensions support and configurable algorithms for more flexible API integration. - -This release includes a breaking change to `client.call_tool()` return signatures but significantly expands the interaction capabilities of MCP servers. -</Card> -</Update> - -<Update label="FastMCP 2.9" description="June 23, 2025" tags={["Releases", "Blog Posts"]}> -<Card -title="FastMCP 2.9: MCP-Native Middleware" href="https://www.jlowin.dev/blog/fastmcp-2-9-middleware" -img="https://jlowin.dev/_image?href=%2F_astro%2Fhero.BkVTdeBk.jpg&w=1200&h=630&f=png" -cta="Read more" -> -FastMCP 2.9 is a major release that, among other things, introduces two important features that push beyond the basic MCP protocol. - -🤝 *MCP Middleware* brings a flexible middleware system for intercepting and controlling server operations - think authentication, logging, rate limiting, and custom business logic without touching core protocol code. - -✨ *Server-side type conversion* for prompts solves a major developer pain point: while MCP requires string arguments, your functions can now work with native Python types like lists and dictionaries, with automatic conversion handling the complexity. - -These features transform FastMCP from a simple protocol implementation into a powerful framework for building sophisticated MCP applications. Combined with the new `File` utility for binary data and improvements to authentication and serialization, this release makes FastMCP significantly more flexible and developer-friendly while maintaining full protocol compliance. -</Card> -</Update> - -<Update label="FastMCP 2.8" description="June 11, 2025" tags={["Releases", "Blog Posts"]}> -<Card -title="FastMCP 2.8: Transform and Roll Out" href="https://www.jlowin.dev/blog/fastmcp-2-8-tool-transformation" -img="https://www.jlowin.dev/_image?href=%2F_astro%2Fhero.su3kspkP.png&w=1000&h=500&f=webp" -cta="Read more" -> -FastMCP 2.8 is here, and it's all about taking control of your tools. - -This release is packed with new features for curating the perfect LLM experience: - -🛠️ Tool Transformation - -The headline feature lets you wrap any tool—from your own code, a third-party library, or an OpenAPI spec—to create an enhanced, LLM-friendly version. You can rename arguments, rewrite descriptions, and hide parameters without touching the original code. - -This feature was developed in close partnership with Bill Easton. As Bill brilliantly [put it](https://www.linkedin.com/posts/williamseaston_huge-thanks-to-william-easton-for-providing-activity-7338011349525983232-Mw6T?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAAd6d0B3uL9zpCsq9eYWKi3HIvb8eN_r_Q), "Tool transformation flips Prompt Engineering on its head: stop writing tool-friendly LLM prompts and start providing LLM-friendly tools." - -🏷️ Component Control - -Now that you're transforming tools, you need a way to hide the old ones! In FastMCP 2.8 you can programmatically enable/disable any component, and for everyone who's been asking what FastMCP's tags are for—they finally have a purpose! You can now use tags to declaratively filter which components are exposed to your clients. - -🚀 Pragmatic by Default - -Lastly, to ensure maximum compatibility with the ecosystem, we've made the pragmatic decision to default all OpenAPI routes to Tools, making your entire API immediately accessible to any tool-using agent. When the industry catches up and supports resources, we'll restore the old default -- but no reason you should do extra work before OpenAI, Anthropic, or Google! - -</Card> -</Update> - -<Update label="FastMCP 2.7" description="June 6, 2025" tags={["Releases"]}> -<Card -title="FastMCP 2.7: Pare Programming" href="https://github.com/PrefectHQ/fastmcp/releases/tag/v2.7.0" -img="assets/updates/release-2-7.png" -cta="Read the release notes" -> -FastMCP 2.7 has been released! - -Most notably, it introduces the highly requested (and Pythonic) "naked" decorator usage: - -```python {3} -mcp = FastMCP() - -@mcp.tool -def add(a: int, b: int) -> int: - return a + b -``` - -In addition, decorators now return the objects they create, instead of the decorated function. This is an important usability enhancement. - -The bulk of the update is focused on improving the FastMCP internals, including a few breaking internal changes to private APIs. A number of functions that have clung on since 1.0 are now deprecated. -</Card> -</Update> - - - -<Update label="FastMCP 2.6" description="June 2, 2025" tags={["Releases", "Blog Posts"]}> -<Card -title="FastMCP 2.6: Blast Auth" href="https://www.jlowin.dev/blog/fastmcp-2-6" -img="https://www.jlowin.dev/_image?href=%2F_astro%2Fhero.Bsu8afiw.png&w=1000&h=500&f=webp" -cta="Read more" -> -FastMCP 2.6 is here! - -This release introduces first-class authentication for MCP servers and clients, including pragmatic Bearer token support and seamless OAuth 2.1 integration. This release aligns with how major AI platforms are adopting MCP today, making it easier than ever to securely connect your tools to real-world AI models. Dive into the update and secure your stack with minimal friction. -</Card> -</Update> - -<Update description="May 21, 2025" label="Vibe-Testing" tags={["Blog Posts", "Tutorials"]}> -<Card -title="Stop Vibe-Testing Your MCP Server" -href="https://www.jlowin.dev/blog/stop-vibe-testing-mcp-servers" -img="https://www.jlowin.dev/_image?href=%2F_astro%2Fhero.BUPy9I9c.png&w=1000&h=500&f=webp" -cta="Read more" -> - -Your tests are bad and you should feel bad. - -Stop vibe-testing your MCP server through LLM guesswork. FastMCP 2.0 introduces in-memory testing for fast, deterministic, and fully Pythonic validation of your MCP logic—no network, no subprocesses, no vibes. - -</Card> -</Update> - - -<Update description="May 8, 2025" label="10,000 Stars" tags={["Blog Posts"]}> -<Card -title="Reflecting on FastMCP at 10k stars 🌟" -href="https://www.jlowin.dev/blog/fastmcp-2-10k-stars" -img="https://www.jlowin.dev/_image?href=%2F_astro%2Fhero.Cnvci9Q_.png&w=1000&h=500&f=webp" -cta="Read more" -> - -In just six weeks since its relaunch, FastMCP has surpassed 10,000 GitHub stars—becoming the fastest-growing OSS project in our orbit. What started as a personal itch has become the backbone of Python-based MCP servers, powering a rapidly expanding ecosystem. While the protocol itself evolves, FastMCP continues to lead with clarity, developer experience, and opinionated tooling. Here’s to what’s next. - -</Card> -</Update> - -<Update description="May 8, 2025" label="FastMCP 2.3" tags={["Blog Posts", "Releases"]}> -<Card -title="Now Streaming: FastMCP 2.3" -href="https://www.jlowin.dev/blog/fastmcp-2-3-streamable-http" -img="https://www.jlowin.dev/_image?href=%2F_astro%2Fhero.M_hv6gEB.png&w=1000&h=500&f=webp" -cta="Read more" -> - -FastMCP 2.3 introduces full support for Streamable HTTP, a modern alternative to SSE that simplifies MCP deployments over the web. It’s efficient, reliable, and now the default HTTP transport. Just run your server with transport="http" and connect clients via a standard URL—FastMCP handles the rest. No special setup required. This release makes deploying MCP servers easier and more portable than ever. - -</Card> -</Update> - -<Update description="April 23, 2025" label="Proxy Servers" tags={["Blog Posts", "Tutorials"]}> -<Card -title="MCP Proxy Servers with FastMCP 2.0" -href="https://www.jlowin.dev/blog/fastmcp-proxy" -img="https://www.jlowin.dev/_image?href=%2F_astro%2Frobot-hero.DpmAqgui.png&w=1000&h=500&f=webp" -cta="Read more" -> - -Even AI needs a good travel adapter 🔌 - - -FastMCP now supports proxying arbitrary MCP servers, letting you run a local FastMCP instance that transparently forwards requests to any remote or third-party server—regardless of transport. This enables transport bridging (e.g., stdio ⇄ SSE), simplified client configuration, and powerful gateway patterns. Proxies are fully composable with other FastMCP servers, letting you mount or import them just like local servers. Use `FastMCP.from_client()` to wrap any backend in a clean, Pythonic proxy. -</Card> -</Update> - -<Update label="FastMCP 2.0" description="April 16, 2025" tags={["Releases", "Blog Posts"]}> -<Card -title="Introducing FastMCP 2.0 🚀" -href="https://www.jlowin.dev/blog/fastmcp-2" -img="https://www.jlowin.dev/_image?href=%2F_astro%2Fhero.DpbmGNrr.png&w=1000&h=500&f=webp" -cta="Read more" -> - -This major release reimagines FastMCP as a full ecosystem platform, with powerful new features for composition, integration, and client interaction. You can now compose local and remote servers, proxy arbitrary MCP servers (with transport translation), and generate MCP servers from OpenAPI or FastAPI apps. A new client infrastructure supports advanced workflows like LLM sampling. - -FastMCP 2.0 builds on the success of v1 with a cleaner, more flexible foundation—try it out today! -</Card> -</Update> - - - -<Update label="Official SDK" description="December 3, 2024" tags={["Announcements"]}> -<Card -title="FastMCP is joining the official MCP Python SDK!" -href="https://bsky.app/profile/jlowin.dev/post/3lch4xk5cf22c" -icon="sparkles" -cta="Read the announcement" -> -FastMCP 1.0 will become part of the official MCP Python SDK! -</Card> -</Update> - - - -<Update label="FastMCP 1.0" description="December 1, 2024" tags={["Releases", "Blog Posts"]}> -<Card -title="Introducing FastMCP 🚀" -href="https://www.jlowin.dev/blog/introducing-fastmcp" -img="https://www.jlowin.dev/_image?href=%2F_astro%2Ffastmcp.Bep7YlTw.png&w=1000&h=500&f=webp" -cta="Read more" -> -Because life's too short for boilerplate. - -This is where it all started. FastMCP’s launch post introduced a clean, Pythonic way to build MCP servers without the protocol overhead. Just write functions; FastMCP handles the rest. What began as a weekend project quickly became the foundation of a growing ecosystem. -</Card> -</Update> diff --git a/examples/apps/qr_server/qr_server.py b/examples/apps/qr_server/qr_server.py index 28ea8d4d1..04639fdb9 100644 --- a/examples/apps/qr_server/qr_server.py +++ b/examples/apps/qr_server/qr_server.py @@ -23,7 +23,7 @@ import base64 import io import qrcode # type: ignore[import-untyped] -from mcp_types import ImageContent +from mcp import types from fastmcp import FastMCP from fastmcp.apps import AppConfig, ResourceCSP @@ -153,7 +153,7 @@ def generate_qr( img.save(buffer, format="PNG") b64 = base64.b64encode(buffer.getvalue()).decode() return ToolResult( - content=[ImageContent(type="image", data=b64, mime_type="image/png")] + content=[types.ImageContent(type="image", data=b64, mimeType="image/png")] ) diff --git a/examples/apps/quiz/quiz_server.py b/examples/apps/quiz/quiz_server.py index f4eacedc3..7de6f08d7 100644 --- a/examples/apps/quiz/quiz_server.py +++ b/examples/apps/quiz/quiz_server.py @@ -28,20 +28,12 @@ from prefab_ui.components import ( Text, ) from prefab_ui.rx import ERROR, RESULT, Rx -from typing_extensions import TypedDict from fastmcp import FastMCP, FastMCPApp app = FastMCPApp("Quiz") - -class Question(TypedDict): - question: str - options: list[str] - correct: int - - -DEFAULT_QUESTIONS: list[Question] = [ +DEFAULT_QUESTIONS = [ { "question": "What is the capital of Australia?", "options": ["Sydney", "Melbourne", "Canberra", "Perth"], @@ -110,7 +102,7 @@ def submit_answer( @app.ui() def take_quiz( topic: str = "General Knowledge", - questions: list[Question] | None = None, + questions: list[dict] | None = None, ) -> PrefabApp: """Launch a quiz UI. diff --git a/examples/apps/sales_dashboard/sales_dashboard_server.py b/examples/apps/sales_dashboard/sales_dashboard_server.py index 04f3781a1..fe38c64bc 100644 --- a/examples/apps/sales_dashboard/sales_dashboard_server.py +++ b/examples/apps/sales_dashboard/sales_dashboard_server.py @@ -1,5 +1,3 @@ -from typing import TypedDict - from prefab_ui.components import ( Card, CardContent, @@ -19,15 +17,7 @@ from fastmcp import FastMCP mcp = FastMCP("Sales Dashboard") - -class MonthlyRevenue(TypedDict): - month: str - new_business: int - expansion: int - renewal: int - - -MONTHLY_REVENUE: list[MonthlyRevenue] = [ +MONTHLY_REVENUE = [ {"month": "Jul", "new_business": 182_000, "expansion": 74_000, "renewal": 210_000}, {"month": "Aug", "new_business": 195_000, "expansion": 81_000, "renewal": 215_000}, {"month": "Sep", "new_business": 224_000, "expansion": 93_000, "renewal": 208_000}, diff --git a/examples/auth/auth0_mcp/README.md b/examples/auth/auth0_mcp/README.md deleted file mode 100644 index e8e0e6bd6..000000000 --- a/examples/auth/auth0_mcp/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Auth0 Auth for MCP Example - -Protects a FastMCP server with Auth0 [Auth for MCP](https://auth0.com/ai/docs/mcp/intro/overview). Auth0 handles OAuth and client registration; FastMCP validates access tokens. - -## Auth0 setup - -1. Enable **Resource Parameter Compatibility Profile** (Settings → Advanced). -2. Create an API whose identifier is `http://127.0.0.1:8000/mcp` (must match the URL logged at server startup). -3. Promote your login connections to domain-level (required for third-party DCR clients). - -See Auth0's [authorization quickstart](https://auth0.com/ai/docs/mcp/get-started/authorization-for-your-mcp-server) for details. - -## Running - -```bash -export AUTH0_CONFIG_URL="https://YOUR_TENANT.auth0.com/.well-known/openid-configuration" -python server.py -``` - -In another terminal: - -```bash -python client.py -``` - -Use `127.0.0.1` consistently — mixing `localhost` and `127.0.0.1` breaks audience validation. - -For troubleshooting (DCR grants, token exchange errors, MCP Inspector), see the [Auth0 integration guide](https://gofastmcp.com/integrations/auth0). diff --git a/examples/auth/auth0_mcp/client.py b/examples/auth/auth0_mcp/client.py deleted file mode 100644 index 24985ae9c..000000000 --- a/examples/auth/auth0_mcp/client.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Auth0 Auth for MCP client example.""" - -import asyncio - -from fastmcp import Client -from fastmcp.client.auth import OAuth - -auth = OAuth( - additional_client_metadata={"token_endpoint_auth_method": "none"}, - callback_host="127.0.0.1", -) - - -async def main() -> None: - async with Client("http://127.0.0.1:8000/mcp", auth=auth) as client: - result = await client.call_tool("echo", {"message": "hello"}) - print(result) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/auth/auth0_mcp/server.py b/examples/auth/auth0_mcp/server.py deleted file mode 100644 index ac08c1c43..000000000 --- a/examples/auth/auth0_mcp/server.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Auth0 Auth for MCP server example. - -Required environment variables: -- AUTH0_CONFIG_URL: OIDC discovery URL for your Auth0 tenant - -To run: - export AUTH0_CONFIG_URL="https://YOUR_TENANT.auth0.com/.well-known/openid-configuration" - python server.py -""" - -import os -import sys - -from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0MCPProvider - -config_url = os.getenv("AUTH0_CONFIG_URL") -if not config_url: - sys.exit( - "AUTH0_CONFIG_URL must be set to your Auth0 OIDC discovery URL, " - 'e.g. "https://YOUR_TENANT.auth0.com/.well-known/openid-configuration"' - ) - -auth = Auth0MCPProvider( - config_url=config_url, - base_url="http://127.0.0.1:8000", -) - -mcp = FastMCP("Auth0 MCP Example Server", auth=auth) - - -@mcp.tool -def echo(message: str) -> str: - """Echo the provided message.""" - return message - - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) diff --git a/examples/auth/aws_oauth/server.py b/examples/auth/aws_oauth/server.py index a854c790c..261164391 100644 --- a/examples/auth/aws_oauth/server.py +++ b/examples/auth/aws_oauth/server.py @@ -48,8 +48,6 @@ def echo(message: str) -> str: async def get_access_token_claims() -> dict: """Get the authenticated user's access token claims.""" token = get_access_token() - if token is None: - return {"error": "Not authenticated"} return { "sub": token.claims.get("sub"), "username": token.claims.get("username"), diff --git a/examples/auth/huggingface_oauth/server.py b/examples/auth/huggingface_oauth/server.py index 5a819aa88..9745eb88a 100644 --- a/examples/auth/huggingface_oauth/server.py +++ b/examples/auth/huggingface_oauth/server.py @@ -2,7 +2,6 @@ import os from fastmcp import FastMCP from fastmcp.server.auth.providers.huggingface import HuggingFaceProvider -from fastmcp.server.dependencies import get_access_token auth_provider = HuggingFaceProvider( # Your Hugging Face OAuth app client ID @@ -22,9 +21,9 @@ mcp = FastMCP(name="Hugging Face Secured App", auth=auth_provider) @mcp.tool async def get_user_info() -> dict: """Returns information about the authenticated Hugging Face user.""" + from fastmcp.server.dependencies import get_access_token + token = get_access_token() - if token is None: - return {"error": "Not authenticated"} return { "subject": token.claims.get("sub"), "username": token.claims.get("preferred_username"), diff --git a/examples/auth/keycloak_oauth/server.py b/examples/auth/keycloak_oauth/server.py index 5bf50b9b1..7b4653103 100644 --- a/examples/auth/keycloak_oauth/server.py +++ b/examples/auth/keycloak_oauth/server.py @@ -33,8 +33,6 @@ def echo(message: str) -> str: async def get_access_token_claims() -> dict: """Get the authenticated user's access token claims.""" token = get_access_token() - if token is None: - return {"error": "Not authenticated"} return { "sub": token.claims.get("sub"), "scope": token.claims.get("scope"), diff --git a/examples/custom_tool_serializer_decorator.py b/examples/custom_tool_serializer_decorator.py index 4993d7356..7075b7238 100644 --- a/examples/custom_tool_serializer_decorator.py +++ b/examples/custom_tool_serializer_decorator.py @@ -11,10 +11,9 @@ from functools import wraps from typing import Any import yaml -from mcp_types import TextContent -from fastmcp import Client, FastMCP -from fastmcp.tools import ToolResult +from fastmcp import FastMCP +from fastmcp.tools.tool import ToolResult def with_serializer(serializer: Callable[[Any], str]): @@ -56,19 +55,18 @@ def get_json_data() -> dict: async def example_usage(): - async with Client(server) as client: - # YAML serialized tool - yaml_result = await client.call_tool("get_example_data", {}) - print("YAML Tool Result:") - if yaml_result.content and isinstance(yaml_result.content[0], TextContent): - print(yaml_result.content[0].text) - print() + # YAML serialized tool + yaml_result = await server._call_tool_mcp("get_example_data", {}) + print("YAML Tool Result:") + print(yaml_result) + print() - # Default JSON serialized tool - json_result = await client.call_tool("get_json_data", {}) - print("JSON Tool Result:") - print(json_result.data) + # Default JSON serialized tool + json_result = await server._call_tool_mcp("get_json_data", {}) + print("JSON Tool Result:") + print(json_result) if __name__ == "__main__": asyncio.run(example_usage()) + server.run() diff --git a/examples/in_memory_proxy_example.py b/examples/in_memory_proxy_example.py index 116e693e6..20cea7360 100644 --- a/examples/in_memory_proxy_example.py +++ b/examples/in_memory_proxy_example.py @@ -9,11 +9,10 @@ It illustrates the pattern: import asyncio -from mcp_types import TextContent - from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server import create_proxy +from fastmcp.types import TextContent class EchoService: diff --git a/examples/providers/sqlite/server.py b/examples/providers/sqlite/server.py index 694a2906e..dd9f19ff6 100644 --- a/examples/providers/sqlite/server.py +++ b/examples/providers/sqlite/server.py @@ -23,7 +23,7 @@ from rich import print from fastmcp import Client, FastMCP from fastmcp.server.providers import Provider -from fastmcp.tools import Tool, ToolResult +from fastmcp.tools.tool import Tool, ToolResult DB_PATH = Path(__file__).parent / "tools.db" diff --git a/examples/sampling/README.md b/examples/sampling/README.md new file mode 100644 index 000000000..3f9225b19 --- /dev/null +++ b/examples/sampling/README.md @@ -0,0 +1,62 @@ +# Sampling Examples + +These examples demonstrate FastMCP's sampling API, which allows server tools to request LLM completions from the client. + +## Prerequisites + +```bash +pip install fastmcp[anthropic] +export ANTHROPIC_API_KEY=your-key +``` + +Or run directly with `uv`: + +```bash +uv run examples/sampling/text.py +``` + +## Examples + +### Simple Text Sampling (`text.py`) + +Basic sampling flow where a server tool requests an LLM completion: + +```bash +uv run examples/sampling/text.py +``` + +### Structured Output (`structured_output.py`) + +Uses `result_type` to get validated Pydantic models from the LLM: + +```bash +uv run examples/sampling/structured_output.py +``` + +### Tool Use (`tool_use.py`) + +Gives the LLM tools to use during sampling (calculator, time, dice): + +```bash +uv run examples/sampling/tool_use.py +``` + +### Server Fallback (`server_fallback.py`) + +Configures a fallback sampling handler on the server, enabling sampling even when clients don't support it: + +```bash +uv run examples/sampling/server_fallback.py +``` + +## Using OpenAI Instead + +To use OpenAI instead of Anthropic, change the handler: + +```python +from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler + +handler = OpenAISamplingHandler(default_model="gpt-4o-mini") +``` + +And install with `pip install fastmcp[openai]`. diff --git a/examples/sampling/server_fallback.py b/examples/sampling/server_fallback.py new file mode 100644 index 000000000..1c3fb10af --- /dev/null +++ b/examples/sampling/server_fallback.py @@ -0,0 +1,88 @@ +# /// script +# dependencies = ["anthropic", "fastmcp", "rich"] +# /// +""" +Server-Side Fallback Handler + +Demonstrates configuring a sampling handler on the server. This ensures +sampling works even when the client doesn't provide a handler. + +The server runs as an HTTP server that can be connected to by any MCP client. + +Run: + uv run examples/sampling/server_fallback.py + +Then connect with any MCP client (e.g., Claude Desktop) or test with: + curl http://localhost:8000/mcp/ +""" + +import asyncio + +from rich.console import Console +from rich.panel import Panel + +from fastmcp import FastMCP +from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler +from fastmcp.server.context import Context + +console = Console() + + +# Create server with a fallback sampling handler +# This handler is used when the client doesn't support sampling +mcp = FastMCP( + "Server with Fallback Handler", + sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"), + sampling_handler_behavior="fallback", # Use only if client lacks sampling +) + + +@mcp.tool +async def summarize(text: str, ctx: Context) -> str: + """Summarize the given text.""" + console.print(f"[bold cyan]SERVER[/] Summarizing text ({len(text)} chars)...") + + result = await ctx.sample( + messages=f"Summarize this text in 1-2 sentences:\n\n{text}", + system_prompt="You are a concise summarizer.", + max_tokens=150, + ) + + console.print("[bold cyan]SERVER[/] Summary complete") + return result.text or "" + + +@mcp.tool +async def translate(text: str, target_language: str, ctx: Context) -> str: + """Translate text to the target language.""" + console.print(f"[bold cyan]SERVER[/] Translating to {target_language}...") + + result = await ctx.sample( + messages=f"Translate to {target_language}:\n\n{text}", + system_prompt=f"You are a translator. Output only the {target_language} translation.", + max_tokens=500, + ) + + console.print("[bold cyan]SERVER[/] Translation complete") + return result.text or "" + + +async def main(): + console.print( + Panel.fit( + "[bold]Server-Side Fallback Handler Demo[/]\n\n" + "This server has a built-in Anthropic handler that activates\n" + "when clients don't provide their own sampling support.", + subtitle="server_fallback.py", + ) + ) + console.print() + console.print("[bold yellow]Starting HTTP server on http://localhost:8000[/]") + console.print("Connect with an MCP client or press Ctrl+C to stop") + console.print() + + await mcp.run_http_async(host="localhost", port=8000) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/sampling/structured_output.py b/examples/sampling/structured_output.py new file mode 100644 index 000000000..fa7afe7ad --- /dev/null +++ b/examples/sampling/structured_output.py @@ -0,0 +1,110 @@ +# /// script +# dependencies = ["anthropic", "fastmcp", "rich"] +# /// +""" +Structured Output Sampling + +Demonstrates using `result_type` to get validated Pydantic models from an LLM. +The server exposes a sentiment analysis tool that returns structured data. + +Run: + uv run examples/sampling/structured_output.py +""" + +import asyncio + +from pydantic import BaseModel +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.sampling import SamplingMessage, SamplingParams +from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler + +console = Console() + + +class LoggingAnthropicHandler(AnthropicSamplingHandler): + async def __call__( + self, messages: list[SamplingMessage], params: SamplingParams, context + ): # type: ignore[override] + console.print(" [bold blue]SAMPLING[/] Calling Claude API...") + result = await super().__call__(messages, params, context) + console.print(" [bold blue]SAMPLING[/] Response received") + return result + + +# Define a structured output model +class SentimentAnalysis(BaseModel): + sentiment: str # "positive", "negative", or "neutral" + confidence: float # 0.0 to 1.0 + keywords: list[str] # Keywords that influenced the analysis + explanation: str # Brief explanation of the analysis + + +# Create the MCP server +mcp = FastMCP("Sentiment Analyzer") + + +@mcp.tool +async def analyze_sentiment(text: str, ctx: Context) -> dict: + """Analyze the sentiment of the given text.""" + console.print(" [bold cyan]SERVER[/] Analyzing sentiment...") + + result = await ctx.sample( + messages=f"Analyze the sentiment of this text:\n\n{text}", + system_prompt="You are a sentiment analysis expert. Analyze text carefully.", + result_type=SentimentAnalysis, + ) + + console.print(" [bold cyan]SERVER[/] Analysis complete") + return result.result.model_dump() # type: ignore[attr-defined] + + +async def main(): + console.print( + Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="structured_output.py") + ) + console.print() + + handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5") + + async with Client(mcp, sampling_handler=handler) as client: + texts = [ + "I absolutely love this product! It exceeded all my expectations.", + "The service was okay, nothing special but got the job done.", + "This is the worst experience I've ever had. Never again.", + ] + + for text in texts: + console.print(f"[bold green]CLIENT[/] Analyzing: [italic]{text[:50]}...[/]") + console.print() + + result = await client.call_tool("analyze_sentiment", {"text": text}) + data = result.data + + # Display results in a table + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column(style="bold") + table.add_column() + + sentiment_color = { + "positive": "green", + "negative": "red", + "neutral": "yellow", + }.get( + data["sentiment"], + "white", # type: ignore[union-attr] + ) + table.add_row("Sentiment", f"[{sentiment_color}]{data['sentiment']}[/]") # type: ignore[index] + table.add_row("Confidence", f"{data['confidence']:.0%}") # type: ignore[index] + table.add_row("Keywords", ", ".join(data["keywords"])) # type: ignore[index] + table.add_row("Explanation", data["explanation"]) # type: ignore[index] + + console.print(Panel(table, border_style=sentiment_color)) + console.print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/sampling/text.py b/examples/sampling/text.py new file mode 100644 index 000000000..6d354e7c2 --- /dev/null +++ b/examples/sampling/text.py @@ -0,0 +1,78 @@ +# /// script +# dependencies = ["anthropic", "fastmcp", "rich"] +# /// +""" +Simple Text Sampling + +Demonstrates the basic MCP sampling flow where a server tool requests +an LLM completion from the client. + +Run: + uv run examples/sampling/text.py +""" + +import asyncio + +from rich.console import Console +from rich.panel import Panel + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.sampling import SamplingMessage, SamplingParams +from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler + +console = Console() + + +# Create a wrapper handler that logs when the LLM is called +class LoggingAnthropicHandler(AnthropicSamplingHandler): + async def __call__( + self, messages: list[SamplingMessage], params: SamplingParams, context + ): # type: ignore[override] + console.print(" [bold blue]SAMPLING[/] Calling Claude API...") + result = await super().__call__(messages, params, context) + console.print(" [bold blue]SAMPLING[/] Response received") + return result + + +# Create the MCP server +mcp = FastMCP("Haiku Generator") + + +@mcp.tool +async def write_haiku(topic: str, ctx: Context) -> str: + """Write a haiku about any topic.""" + console.print( + f" [bold cyan]SERVER[/] Tool 'write_haiku' called with topic: {topic}" + ) + + result = await ctx.sample( + messages=f"Write a haiku about: {topic}", + system_prompt="You are a poet. Write only the haiku, nothing else.", + max_tokens=100, + ) + + console.print(" [bold cyan]SERVER[/] Returning haiku to client") + return result.text or "" + + +async def main(): + console.print(Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="text.py")) + console.print() + + # Create the sampling handler + handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5") + + # Connect client to server with the sampling handler + async with Client(mcp, sampling_handler=handler) as client: + console.print("[bold green]CLIENT[/] Calling tool 'write_haiku'...") + console.print() + + result = await client.call_tool("write_haiku", {"topic": "Python programming"}) + + console.print() + console.print("[bold green]CLIENT[/] Received result:") + console.print(Panel(result.data, title="Haiku", border_style="green")) # type: ignore[arg-type] + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/sampling/tool_use.py b/examples/sampling/tool_use.py new file mode 100644 index 000000000..e7869a16c --- /dev/null +++ b/examples/sampling/tool_use.py @@ -0,0 +1,125 @@ +# /// script +# dependencies = ["anthropic", "fastmcp", "rich"] +# /// +""" +Sampling with Tools + +Demonstrates giving an LLM tools to use during sampling. The LLM can call +helper functions to gather information before responding. + +Run: + uv run examples/sampling/tool_use.py +""" + +import asyncio +import random +from datetime import datetime + +from pydantic import BaseModel, Field +from rich.console import Console +from rich.panel import Panel + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.sampling import SamplingMessage, SamplingParams +from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler + +console = Console() + + +class LoggingAnthropicHandler(AnthropicSamplingHandler): + async def __call__( + self, messages: list[SamplingMessage], params: SamplingParams, context + ): # type: ignore[override] + console.print(" [bold blue]SAMPLING[/] Calling Claude API...") + result = await super().__call__(messages, params, context) + console.print(" [bold blue]SAMPLING[/] Response received") + return result + + +# Define tools available to the LLM during sampling +def add(a: float, b: float) -> str: + """Add two numbers together.""" + result = a + b + console.print(f" [bold magenta]TOOL[/] add({a}, {b}) = {result}") + return str(result) + + +def multiply(a: float, b: float) -> str: + """Multiply two numbers together.""" + result = a * b + console.print(f" [bold magenta]TOOL[/] multiply({a}, {b}) = {result}") + return str(result) + + +def get_current_time() -> str: + """Get the current date and time.""" + console.print(" [bold magenta]TOOL[/] get_current_time()") + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def roll_dice(sides: int = 6) -> str: + """Roll a die with the specified number of sides.""" + result = random.randint(1, sides) + console.print(f" [bold magenta]TOOL[/] roll_dice({sides}) = {result}") + return str(result) + + +# Structured output for the response +class AssistantResponse(BaseModel): + answer: str = Field(description="The answer to the user's question") + tools_used: list[str] = Field(description="List of tools that were used") + reasoning: str = Field( + description="Brief explanation of how the answer was determined" + ) + + +# Create the MCP server +mcp = FastMCP("Smart Assistant") + + +@mcp.tool +async def ask_assistant(question: str, ctx: Context) -> dict: + """Ask the assistant a question. It can use tools to help answer.""" + console.print(" [bold cyan]SERVER[/] Processing question...") + + result = await ctx.sample( + messages=question, + system_prompt="You are a helpful assistant with access to tools. Use them when needed to answer questions accurately.", + tools=[add, multiply, get_current_time, roll_dice], + result_type=AssistantResponse, + ) + + console.print(" [bold cyan]SERVER[/] Response ready") + return result.result.model_dump() # type: ignore[attr-defined] + + +async def main(): + console.print(Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="tool_use.py")) + console.print() + + handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5") + + async with Client(mcp, sampling_handler=handler) as client: + questions = [ + "What is 15 times 7, plus 23?", + "Roll a 20-sided dice for me", + "What time is it right now?", + ] + + for question in questions: + console.print(f"[bold green]CLIENT[/] Question: {question}") + console.print() + + result = await client.call_tool("ask_assistant", {"question": question}) + data = result.data + + console.print(f"[bold green]CLIENT[/] Answer: {data['answer']}") # type: ignore[index] + console.print( + f" Tools used: {', '.join(data['tools_used']) or 'none'}" + ) # type: ignore[index] + console.print(f" Reasoning: {data['reasoning']}") # type: ignore[index] + console.print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/skills/client.py b/examples/skills/client.py index d5005ac23..a376fe235 100644 --- a/examples/skills/client.py +++ b/examples/skills/client.py @@ -41,7 +41,7 @@ async def main(): print("=== Resource Templates ===") templates = await client.list_resource_templates() for t in templates: - print(f" {t.uri_template}") + print(f" {t.uriTemplate}") print() # Read a skill's main file diff --git a/examples/skills/download_skills.py b/examples/skills/download_skills.py index c6e571c57..69b8d0373 100644 --- a/examples/skills/download_skills.py +++ b/examples/skills/download_skills.py @@ -1,7 +1,7 @@ """Example: Downloading skills from an MCP server. This example shows how to use the skills client utilities to discover -and download skills from any MCP server that exposes them via a skills provider. +and download skills from any MCP server that exposes them via SkillsProvider. Run this script: uv run python examples/skills/download_skills.py diff --git a/examples/smart_home/src/smart_home/hub.py b/examples/smart_home/src/smart_home/hub.py index be2efc2f1..30c4f24d2 100644 --- a/examples/smart_home/src/smart_home/hub.py +++ b/examples/smart_home/src/smart_home/hub.py @@ -1,7 +1,7 @@ -from mcp_types import ToolAnnotations from phue import Bridge from fastmcp import FastMCP +from fastmcp.types import ToolAnnotations from smart_home.lights.server import lights_mcp from smart_home.settings import settings diff --git a/examples/smart_home/src/smart_home/lights/server.py b/examples/smart_home/src/smart_home/lights/server.py index 6fffcb298..535b0f10d 100644 --- a/examples/smart_home/src/smart_home/lights/server.py +++ b/examples/smart_home/src/smart_home/lights/server.py @@ -7,12 +7,12 @@ from typing import Annotated, Any, Literal, TypedDict -from mcp_types import ToolAnnotations from phue.exceptions import PhueException from pydantic import Field from typing_extensions import NotRequired from fastmcp import FastMCP +from fastmcp.types import ToolAnnotations from smart_home.lights.hue_utils import _get_bridge, handle_phue_error diff --git a/examples/task_elicitation.py b/examples/task_elicitation.py index 18d56f2e7..2c7852e03 100644 --- a/examples/task_elicitation.py +++ b/examples/task_elicitation.py @@ -1,16 +1,8 @@ """ -Background task input demo (SEP-2663 guard pattern). +Background task elicitation demo. -A background task that pauses to ask the user a question, waits for the answer, -then resumes and finishes. Under SEP-2663 a task gathers input by the *guard -pattern*: instead of awaiting `ctx.elicit()` (which would block a worker), the -tool *returns* an `InputRequiredResult`. That ends the leg; the client answers -via the tasks protocol; the framework re-runs the tool with the answer on -`ctx.input_responses`. No worker is ever blocked. - -The client side is transparent: `client.call_tool(...)` drives the whole -round-trip — poll, answer via the `elicitation_handler`, poll again — and returns -the finished result. +A background task (Docket) that pauses mid-execution to ask the user a +question, waits for the answer, then resumes and finishes. Works with both in-memory and Redis backends: @@ -30,15 +22,12 @@ Requires the `docket` extra (included in dev dependencies). import asyncio from dataclasses import dataclass -import mcp_types -from mcp_types import TextContent - from fastmcp import Context, FastMCP from fastmcp.client import Client -from fastmcp_tasks import TasksExtension +from fastmcp.server.elicitation import AcceptedElicitation +from fastmcp.types import TextContent mcp = FastMCP("Task Elicitation Demo") -mcp.add_extension(TasksExtension()) @dataclass @@ -47,60 +36,43 @@ class DinnerPrefs: vegetarian: bool -def _ask_dinner_prefs() -> mcp_types.InputRequiredResult: - """Return the input request that pauses the task until the client answers.""" - request = mcp_types.ElicitRequest( - params=mcp_types.ElicitRequestFormParams( - message="What kind of dinner are you in the mood for?", - requested_schema={ - "type": "object", - "properties": { - "cuisine": {"type": "string"}, - "vegetarian": {"type": "boolean"}, - }, - "required": ["cuisine", "vegetarian"], - }, - ) - ) - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={"prefs": request}, - ) - - @mcp.tool(task=True) -async def plan_dinner(ctx: Context) -> str | mcp_types.InputRequiredResult: +async def plan_dinner(ctx: Context) -> str: """Plan a dinner menu, asking the user what they're in the mood for.""" - responses = ctx.input_responses - if responses is None: - # First leg: ask for preferences and end the leg. - return _ask_dinner_prefs() - # Re-entered leg: the client's answer is on ctx.input_responses. - answer = responses["prefs"] - assert isinstance(answer, mcp_types.ElicitResult) - if answer.action != "accept" or answer.content is None: + await ctx.report_progress(0, 2, "Asking what you'd like...") + + result = await ctx.elicit( + "What kind of dinner are you in the mood for?", + response_type=DinnerPrefs, + ) + + if not isinstance(result, AcceptedElicitation): return "Dinner cancelled!" - await asyncio.sleep(1) # "planning the menu" - veg = "vegetarian " if answer.content["vegetarian"] else "" - return f"Tonight's menu: a lovely {veg}{answer.content['cuisine']} dinner!" + prefs = result.data + await ctx.report_progress(1, 2, "Planning your menu...") + await asyncio.sleep(1) + await ctx.report_progress(2, 2, "Done!") + + veg = "vegetarian " if prefs.vegetarian else "" + return f"Tonight's menu: a lovely {veg}{prefs.cuisine} dinner!" async def handle_elicitation(message, response_type, params, context): - """Answer elicitation requests raised by the background task.""" + """Handle elicitation requests from background tasks.""" print(f" Server asks: {message}") print(" Responding with: cuisine=Thai, vegetarian=True") return DinnerPrefs(cuisine="Thai", vegetarian=True) async def main(): - client = Client(mcp, mode="auto", elicitation_handler=handle_elicitation) - async with client: - print("Calling plan_dinner (runs as a background task)...") - # call_tool drives the whole round-trip transparently: it polls, answers - # the task's input request via handle_elicitation, and returns the result. - result = await client.call_tool("plan_dinner", {}) + async with Client(mcp, elicitation_handler=handle_elicitation) as client: + print("Starting background task...") + task = await client.call_tool("plan_dinner", {}, task=True) + print(f" task_id = {task.task_id}\n") + + result = await task.result() assert isinstance(result.content[0], TextContent) print(f"\nResult: {result.content[0].text}") diff --git a/examples/tasks/.envrc b/examples/tasks/.envrc index 7c90adf43..87a7dfef9 100644 --- a/examples/tasks/.envrc +++ b/examples/tasks/.envrc @@ -1,11 +1,10 @@ # FastMCP Tasks Example Environment Configuration -# Loaded by direnv (https://direnv.net/) when you cd into this directory. -# Run `direnv allow` to enable automatic loading — or just `source .envrc`. +# This file is loaded by direnv (https://direnv.net/) when you cd into this directory +# Run `direnv allow` to enable automatic environment loading -# In-process worker on an in-memory backend: no Redis, nothing to start. -# This is the default the example runs on. -export FASTMCP_DOCKET_URL=memory:// +# Configure Docket backend URL +# Use Redis backend (requires docker-compose up) +export FASTMCP_DOCKET_URL=redis://localhost:24242/0 -# For distributed workers across separate processes (the `fastmcp tasks worker` -# CLI), point at Redis instead and run `docker compose up -d` first: -# export FASTMCP_DOCKET_URL=redis://localhost:24242/0 +# Or uncomment to use memory:// for single-process testing +# export FASTMCP_DOCKET_URL=memory:// diff --git a/examples/tasks/README.md b/examples/tasks/README.md index d9f2dab5a..8013968d5 100644 --- a/examples/tasks/README.md +++ b/examples/tasks/README.md @@ -1,75 +1,60 @@ -# FastMCP Background Tasks Example +# FastMCP Tasks Example -A runnable client/server pair for SEP-2663 background tasks. The server exposes -one `task=True` tool that reports progress as it works; the client drives it -three ways — transparently, through an explicit handle, and several at once in -parallel. +Demonstrates background task execution with Docket, including progress tracking, distributed backends, and CLI worker management. -This runs on the in-memory backend by default, so there's nothing to install or -start beyond the two processes. - -## Run it - -In one terminal, start the server: +## Setup ```bash -uv sync # from the fastmcp root, once -python examples/tasks/server.py # listens on http://127.0.0.1:8000/mcp -``` +# From the fastmcp root directory +uv sync -In another terminal, drive it from the client: - -```bash -# Transparent — call_tool runs the background task and returns its result -python examples/tasks/client.py --duration 8 - -# Explicit handle — returns immediately, poll it yourself, then collect -python examples/tasks/client.py handle --duration 6 - -# Parallel — fire several tasks at once and watch them overlap -python examples/tasks/client.py parallel -python examples/tasks/client.py parallel 8 6 4 2 -``` - -The `parallel` run is the one to watch: four tasks of decreasing duration all -start at once and total wall-clock tracks the *longest* task rather than the -sum, because the worker runs them concurrently. - -## How it works - -The server enables tasks with one line: - -```python -mcp = FastMCP("Tasks Example") -mcp.add_extension(TasksExtension()) -``` - -The client opts in by importing `fastmcp_tasks` (which it does to use -`call_tool_task`). That single import enables task support for every `Client` -in the process — without it, a `Client` never advertises the tasks capability, -so the server would run the calls synchronously. - -## Distributed workers (optional) - -The default `memory://` backend runs the worker in the server process. To run -workers as separate processes, point Docket at Redis and start it first: - -```bash +# Start Redis cd examples/tasks docker compose up -d -export FASTMCP_DOCKET_URL=redis://localhost:24242/0 # or: direnv allow -python server.py # in one terminal -python -m fastmcp_tasks.worker_cli worker server.py # extra worker(s) in others +# Load environment (or source .envrc manually) +direnv allow + +# Run the server +fastmcp run server.py ``` -| Backend | Workers | -| ------------ | ------------------------------- | -| `memory://` | in-process only (default) | -| `redis://…` | distributed across processes | +For single-process mode without Redis, set `FASTMCP_DOCKET_URL=memory://` (note: CLI workers won't work). -## Learn more +## Running the Client -- [Server background tasks](https://gofastmcp.com/servers/tasks) -- [Client background tasks](https://gofastmcp.com/clients/tasks) -- [Docket](https://github.com/chrisguidry/docket) +```bash +# Background execution with progress callbacks +python examples/tasks/client.py --duration 10 + +# Immediate execution (blocks) +python examples/tasks/client.py immediate --duration 5 +``` + +## Starting Additional Workers + +With Redis, you can run additional workers to process tasks in parallel: + +```bash +fastmcp tasks worker server.py + +# Configure via environment: +export FASTMCP_DOCKET_CONCURRENCY=20 +fastmcp tasks worker server.py +``` + +**Backend options:** +- `memory://` - Single-process only (default) +- `redis://` - Distributed, multi-process (Redis or Valkey) + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `FASTMCP_DOCKET_URL` | `memory://` | Docket backend URL | + +## Learn More + +- [FastMCP Tasks Documentation](https://gofastmcp.com/docs/tasks) +- [Docket Documentation](https://github.com/PrefectHQ/docket) +- [MCP Task Protocol (SEP-1686)](https://spec.modelcontextprotocol.io/specification/architecture/tasks/) diff --git a/examples/tasks/client.py b/examples/tasks/client.py index fe93ea23b..ac1dacf7c 100644 --- a/examples/tasks/client.py +++ b/examples/tasks/client.py @@ -1,137 +1,161 @@ -"""FastMCP background-tasks example client (SEP-2663). +""" +FastMCP Tasks Example Client -Start the server first (`python examples/tasks/server.py`), then run any of the -commands below against it over HTTP. +Demonstrates calling tools both immediately and as background tasks, +with real-time progress updates via status callbacks. - # Transparent: call_tool drives the background task and returns its result - python examples/tasks/client.py --duration 8 +Usage: + # Make sure environment is configured (source .envrc or use direnv) + source .envrc - # Explicit handle: return immediately, poll it yourself, then collect - python examples/tasks/client.py handle --duration 6 + # Background task execution with progress callbacks (default) + python client.py --duration 10 - # Parallel: fire several tasks at once and watch them overlap - python examples/tasks/client.py parallel - -Importing `fastmcp_tasks` (below) enables client task support for every Client -in the process — without it, a Client never advertises the tasks capability and -the server runs its calls synchronously. + # Immediate execution (blocks until complete) + python client.py immediate --duration 5 """ import asyncio -import time +import sys +from pathlib import Path from typing import Annotated import cyclopts -from mcp_types import TextContent +from mcp_types import GetTaskResult from rich.console import Console from fastmcp.client import Client -from fastmcp_tasks import call_tool_task # importing enables client task support - -SERVER_URL = "http://127.0.0.1:8000/mcp" +from fastmcp.types import TextContent console = Console() -app = cyclopts.App(name="tasks-client", help="FastMCP background-tasks example client") +app = cyclopts.App(name="tasks-client", help="FastMCP Tasks Example Client") -def _text(result) -> str: - assert isinstance(result.content[0], TextContent) - return result.content[0].text +def load_server(): + """Load the example server.""" + examples_dir = Path(__file__).parent.parent.parent + if str(examples_dir) not in sys.path: + sys.path.insert(0, str(examples_dir)) + + import examples.tasks.server as server_module + + return server_module.mcp + + +# Track last message to deduplicate consecutive identical notifications +# Note: Docket fires separate events for progress.increment() and progress.set_message(), +# but MCP's status_message field only carries the text message (no numerical progress). +# This means we often get duplicate notifications with identical messages. +_last_notification_message = None + + +def print_notification(status: GetTaskResult) -> None: + """Callback function for push notifications from server. + + This is called automatically when the server sends notifications/tasks/status. + Deduplicates identical consecutive messages to keep output clean. + """ + global _last_notification_message + + # Skip if this is the same message we just printed + if status.status_message == _last_notification_message: + return + + _last_notification_message = status.status_message + + color = { + "working": "yellow", + "completed": "green", + "failed": "red", + }.get(status.status, "yellow") + + icon = { + "working": "🚀", + "completed": "✅", + "failed": "❌", + }.get(status.status, "⚠️") + + console.print( + f"[{color}]📢 Notification: {status.status} {icon} - {status.status_message}[/{color}]" + ) @app.default -async def transparent( - duration: Annotated[int, cyclopts.Parameter(help="Seconds (1-60)")] = 8, +async def task( + duration: Annotated[ + int, + cyclopts.Parameter(help="Duration of computation in seconds (1-60)"), + ] = 10, ): - """Call the tool transparently: the client drives the task to completion. + """Execute as background task with real-time progress callbacks.""" + if duration < 1 or duration > 60: + console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]") + sys.exit(1) - The server runs `slow_computation` as a background task, but `call_tool` - polls it under the hood and returns the tool's real result — the calling - code looks exactly like an ordinary synchronous tool call. - """ - async with Client(SERVER_URL, mode="auto") as client: - console.print(f"\n[bold]Transparent call[/bold] (duration={duration})\n") - started = time.perf_counter() - result = await client.call_tool( + server = load_server() + + console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]") + console.print("Mode: [cyan]Background task[/cyan]\n") + + async with Client(server) as client: + task_obj = await client.call_tool( "slow_computation", - {"label": "transparent", "duration": duration}, + arguments={"duration": duration}, + task=True, ) - console.print(f"[green]{_text(result)}[/green]") - console.print(f"[dim]elapsed {time.perf_counter() - started:.1f}s[/dim]") + console.print(f"Task started: [cyan]{task_obj.task_id}[/cyan]\n") -@app.command -async def handle( - duration: Annotated[int, cyclopts.Parameter(help="Seconds (1-60)")] = 6, -): - """Use the explicit handle: return immediately, then drive the task yourself.""" - async with Client(SERVER_URL, mode="auto") as client: - console.print(f"\n[bold]Explicit handle[/bold] (duration={duration})\n") - task = await call_tool_task( - client, "slow_computation", {"label": "handle", "duration": duration} + # Register callback for real-time push notifications + task_obj.on_status_change(print_notification) + + console.print( + "[dim]Notifications will appear as the server sends them...[/dim]\n" ) - console.print(f"Task started: [cyan]{task.task_id}[/cyan]\n") - # Do other work while the task runs, checking its status as you go. - while True: - status = await task.status() - if status.status in ("completed", "failed", "cancelled"): - break - console.print(f"[dim]still {status.status}: {status.status_message}[/dim]") - await asyncio.sleep(1) - - result = await task.result() - console.print(f"\n[green]{_text(result)}[/green]") - - -@app.command -async def parallel( - durations: Annotated[ - list[int] | None, - cyclopts.Parameter(help="One task per duration (default: 5 4 3 2)"), - ] = None, -): - """Fire several background tasks at once and drive them concurrently. - - Each `call_tool_task` returns immediately, so we start every task before - awaiting any of them. The worker runs them in parallel, so total wall-clock - tracks the *longest* task, not the sum — proof the work actually overlaps. - """ - durations = durations or [5, 4, 3, 2] - - async with Client(SERVER_URL, mode="auto") as client: - console.print(f"\n[bold]Parallel tasks[/bold]: durations={durations}\n") - started = time.perf_counter() - - # Start every task up front — none of these await completion. - tasks = [ - await call_tool_task( - client, - "slow_computation", - {"label": f"task-{i}({d}s)", "duration": d}, - ) - for i, d in enumerate(durations) - ] - for task in tasks: - console.print(f" started [cyan]{task.task_id}[/cyan]") - - # Await them together; results print as each task finishes. - async def collect(task): - result = await task.result() - console.print( - f"[green]✓[/green] {_text(result)} " - f"[dim](+{time.perf_counter() - started:.1f}s)[/dim]" - ) + # Do other work while task runs in background + for i in range(3): + await asyncio.sleep(0.5) + console.print(f"[dim]Client doing other work... ({i + 1}/3)[/dim]") console.print() - await asyncio.gather(*(collect(task) for task in tasks)) - total = time.perf_counter() - started - console.print( - f"\n[bold]All {len(tasks)} tasks done in {total:.1f}s[/bold] " - f"[dim](longest single task: {max(durations)}s)[/dim]" + # Wait for task to complete + console.print("[dim]Waiting for final result...[/dim]") + result = await task_obj.result() + + console.print("\n[bold]Result:[/bold]") + assert isinstance(result.content[0], TextContent) + console.print(f" {result.content[0].text}") + + +@app.command +async def immediate( + duration: Annotated[ + int, + cyclopts.Parameter(help="Duration of computation in seconds (1-60)"), + ] = 5, +): + """Execute the tool immediately (blocks until complete).""" + if duration < 1 or duration > 60: + console.print("[red]Error: Duration must be between 1 and 60 seconds[/red]") + sys.exit(1) + + server = load_server() + + console.print(f"\n[bold]Calling slow_computation(duration={duration})[/bold]") + console.print("Mode: [cyan]Immediate execution[/cyan]\n") + + async with Client(server) as client: + result = await client.call_tool( + "slow_computation", + arguments={"duration": duration}, ) + console.print("\n[bold]Result:[/bold]") + assert isinstance(result.content[0], TextContent) + console.print(f" {result.content[0].text}") + if __name__ == "__main__": app() diff --git a/examples/tasks/server.py b/examples/tasks/server.py index 745009ef7..77b3cde82 100644 --- a/examples/tasks/server.py +++ b/examples/tasks/server.py @@ -1,65 +1,75 @@ -"""FastMCP background-tasks example server (SEP-2663). +""" +FastMCP Tasks Example Server -Run this in one terminal, then drive it from `client.py` in another. It exposes -one `task=True` tool that reports progress as it works, so you can watch the -client poll a real background task over HTTP. +Demonstrates background task execution with progress tracking using Docket. - # From the fastmcp root (memory:// backend, no Redis needed): - python examples/tasks/server.py +Setup: + 1. Start Redis: docker compose up -d + 2. Load environment: source .envrc + 3. Run server: fastmcp run server.py -The server listens on http://localhost:8000/mcp. The tasks extension runs its -Docket worker in-process on the default `memory://` backend, so several tasks -submitted at once execute concurrently (worker concurrency defaults to 10). -Point `FASTMCP_DOCKET_URL` at Redis to distribute work across separate worker -processes instead — see README.md. +The example uses Redis by default to demonstrate distributed task execution +and the fastmcp tasks CLI commands. """ import asyncio import logging -from datetime import timedelta from typing import Annotated +from docket import Logged + from fastmcp import FastMCP from fastmcp.dependencies import Progress -from fastmcp.utilities.tasks import TaskConfig -from fastmcp_tasks import TasksExtension -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") -logger = logging.getLogger("tasks-example") +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) -# Enable SEP-2663 background tasks. With no arguments the extension reads the -# FASTMCP_DOCKET_* environment and falls back to an in-process memory:// worker. +# Create server mcp = FastMCP("Tasks Example") -mcp.add_extension(TasksExtension()) -# A short poll interval keeps the example snappy: the client observes each -# task finishing within ~1s. The default is 5s, tuned for real workloads. -@mcp.tool(task=TaskConfig(poll_interval=timedelta(seconds=1))) +@mcp.tool(task=True) async def slow_computation( - label: Annotated[str, "A name for this run, echoed back in progress logs"], - duration: Annotated[int, "How many seconds the computation should take (1-60)"], + duration: Annotated[int, Logged], progress: Progress = Progress(), ) -> str: - """Spend `duration` seconds working, reporting progress once per second. - - Marked `task=True`, so a task-aware client runs it in the background and - polls for progress and the final result instead of blocking on the call. """ - if not 1 <= duration <= 60: - raise ValueError("duration must be between 1 and 60 seconds") + Perform a slow computation that takes `duration` seconds. - logger.info("[%s] starting — %ds", label, duration) + This tool demonstrates progress tracking with background tasks. + It logs progress every 1-2 seconds and reports progress via Docket. + + Args: + duration: Number of seconds the computation should take (1-60) + + Returns: + A completion message with the total duration + """ + if duration < 1 or duration > 60: + raise ValueError("Duration must be between 1 and 60 seconds") + + logger.info(f"Starting slow computation for {duration} seconds") + + # Set total progress units await progress.set_total(duration) - for elapsed in range(1, duration + 1): + # Process each second + for i in range(duration): + # Sleep for 1 second await asyncio.sleep(1) + + # Update progress + elapsed = i + 1 + remaining = duration - elapsed await progress.increment() - await progress.set_message(f"{label}: {elapsed}/{duration}s") + await progress.set_message( + f"Working... {elapsed}/{duration}s ({remaining}s remaining)" + ) - logger.info("[%s] done", label) - return f"{label} finished in {duration}s" + # Log every 1-2 seconds + if elapsed % 2 == 0 or elapsed == duration: + logger.info(f"Progress: {elapsed}/{duration}s") - -if __name__ == "__main__": - mcp.run(transport="http", host="127.0.0.1", port=8000) + logger.info(f"Completed computation in {duration} seconds") + return f"Computation completed successfully in {duration} seconds!" diff --git a/examples/testing_demo/README.md b/examples/testing_demo/README.md index 346dc8cc8..e4e711111 100644 --- a/examples/testing_demo/README.md +++ b/examples/testing_demo/README.md @@ -81,4 +81,4 @@ uv run fastmcp inspect server.py ## Learning More -For detailed information about testing FastMCP servers, see the [Testing Documentation](../../docs/servers/testing.mdx). +For detailed information about testing FastMCP servers, see the [Testing Documentation](../../docs/patterns/testing.mdx). diff --git a/examples/testing_demo/uv.lock b/examples/testing_demo/uv.lock index a9cb8f163..966d76b33 100644 --- a/examples/testing_demo/uv.lock +++ b/examples/testing_demo/uv.lock @@ -1,12 +1,6 @@ version = 1 revision = 3 requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", -] [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. @@ -15,13 +9,7 @@ exclude-newer-span = "P1W" [options.exclude-newer-package] mcp-types = false prefab-ui = false -truststore = false -fastmcp-slim = false -fastmcp = false mcp = false -httpcore2 = false -fastmcp-remote = false -httpx2 = false [[package]] name = "aiofile" @@ -639,7 +627,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -657,9 +645,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, ] [[package]] @@ -1274,8 +1262,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/examples/text_me.py b/examples/text_me.py index 2a90f06d4..26a0350ab 100644 --- a/examples/text_me.py +++ b/examples/text_me.py @@ -28,7 +28,9 @@ from fastmcp import FastMCP class SurgeSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="SURGE_", env_file=".env") + model_config: SettingsConfigDict = SettingsConfigDict( + env_prefix="SURGE_", env_file=".env" + ) api_key: str account_id: str @@ -41,7 +43,7 @@ class SurgeSettings(BaseSettings): # Create server mcp = FastMCP("Text me") -surge_settings = SurgeSettings() # type: ignore[call-arg] +surge_settings = SurgeSettings() # type: ignore @mcp.tool(name="textme", description="Send a text message to me") diff --git a/examples/tool_result_echo.py b/examples/tool_result_echo.py index 54ed151de..bd1185f29 100644 --- a/examples/tool_result_echo.py +++ b/examples/tool_result_echo.py @@ -10,7 +10,7 @@ import time from dataclasses import dataclass from fastmcp import FastMCP -from fastmcp.tools import ToolResult +from fastmcp.tools.tool import ToolResult mcp = FastMCP("Echo Server") diff --git a/fastmcp_slim/README.md b/fastmcp_slim/README.md index 4a813d96b..9afc7f4f7 100644 --- a/fastmcp_slim/README.md +++ b/fastmcp_slim/README.md @@ -100,10 +100,9 @@ uv pip install fastmcp For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation). **Upgrading?** We have guides for: -- [Upgrading from FastMCP 3](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-3) -- [Upgrading from FastMCP 2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) -- [Upgrading from MCP SDK v1](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk-v2) -- [Upgrading from the low-level SDK v1](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v1) or [v2](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk-v2) +- [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) +- [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk) +- [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk) ## 📚 Documentation diff --git a/fastmcp_slim/fastmcp/__init__.py b/fastmcp_slim/fastmcp/__init__.py index 9d64f128e..0e1c33bf0 100644 --- a/fastmcp_slim/fastmcp/__init__.py +++ b/fastmcp_slim/fastmcp/__init__.py @@ -5,14 +5,20 @@ import warnings from importlib.metadata import PackageNotFoundError, version as _version from typing import TYPE_CHECKING -from fastmcp import _install_hints -from fastmcp._warnings import FastMCPDeprecationWarning +from fastmcp import _install_hints, _sdk_patches from fastmcp.settings import Settings from fastmcp.utilities.logging import configure_logging as _configure_logging +# Apply temporary SDK registry patches (SEP-1686 task methods) before any +# client/server use. See fastmcp._sdk_patches for the upstream-gap rationale. +_sdk_patches.install() + if TYPE_CHECKING: from fastmcp.client import Client as Client from fastmcp.apps.app import FastMCPApp as FastMCPApp + from fastmcp.exceptions import ( + FastMCPDeprecationWarning as FastMCPDeprecationWarning, + ) from fastmcp.server.context import Context as Context from fastmcp.server.server import FastMCP as FastMCP @@ -37,7 +43,12 @@ except PackageNotFoundError: __version__ = _version("fastmcp") if settings.deprecation_warnings: - warnings.simplefilter("default", FastMCPDeprecationWarning) + try: + from fastmcp.exceptions import FastMCPDeprecationWarning + except ImportError: + pass + else: + warnings.simplefilter("default", FastMCPDeprecationWarning) # --- Lazy imports for performance (see #3292) --- @@ -74,6 +85,10 @@ def __getattr__(name: str) -> object: raise ImportError(_install_hints.APP_SUPPORT) from exc return FastMCPApp + if name == "FastMCPDeprecationWarning": + from fastmcp.exceptions import FastMCPDeprecationWarning + + return FastMCPDeprecationWarning if name == "client": try: return importlib.import_module("fastmcp.client") diff --git a/fastmcp_slim/fastmcp/_compat.py b/fastmcp_slim/fastmcp/_compat.py index cefc67b49..f5fcc7f81 100644 --- a/fastmcp_slim/fastmcp/_compat.py +++ b/fastmcp_slim/fastmcp/_compat.py @@ -33,7 +33,7 @@ import warnings import mcp_types -from fastmcp._warnings import FastMCPDeprecationWarning +from fastmcp.exceptions import FastMCPDeprecationWarning # Map each SDK model class to the camelCase -> snake_case field reads we bridge. # Limited to fields FastMCP users actually read (docs boundary inventory). @@ -42,12 +42,6 @@ _ALIASES: dict[type, dict[str, str]] = { "inputSchema": "input_schema", "outputSchema": "output_schema", }, - mcp_types.ToolAnnotations: { - "readOnlyHint": "read_only_hint", - "destructiveHint": "destructive_hint", - "idempotentHint": "idempotent_hint", - "openWorldHint": "open_world_hint", - }, mcp_types.Resource: { "mimeType": "mime_type", }, diff --git a/fastmcp_slim/fastmcp/_sdk_patches.py b/fastmcp_slim/fastmcp/_sdk_patches.py new file mode 100644 index 000000000..a77765cfc --- /dev/null +++ b/fastmcp_slim/fastmcp/_sdk_patches.py @@ -0,0 +1,131 @@ +"""Temporary in-place patches for gaps in the pinned MCP SDK. + +## SEP-1686 task methods missing from the handshake-era method registries + +This shim compensates for a genuine gap in the SDK's *handshake-era* +(2025-11-25 and earlier) task registry. In the 2025-11-25 SEP-1686 model, tasks +are a first-class part of the core protocol: `CallToolRequestParams` carries a +`task: TaskMetadata` field and a task-augmented `tools/call` returns a +`CreateTaskResult`. `mcp==2.0.0b1` ships those task types (`CreateTaskResult`, +`GetTaskResult`, `GetTaskPayloadResult`, `ListTasksResult`, `CancelTaskResult`) +and the `task` request field, but its `mcp_types.methods` registries were never +wired for them: there are no `tasks/*` rows, and the handshake-era `tools/call` +result rows are a plain `CallToolResult` with no `CreateTaskResult` arm. + +The lowlevel server runner (`mcp.server.runner`) serializes a handler's result +through `serialize_server_result(method, version, ...)` for any method in +`SPEC_CLIENT_METHODS`. `tools/call` is such a method, so when a FastMCP tool is +submitted as a background task (`client.call_tool(..., task=True)`) the handler +returns a `CreateTaskResult`, which fails validation against the un-widened +`tools/call` surface row -> the client sees "Handler returned an invalid +result". The `tasks/*` methods themselves are NOT in `SPEC_CLIENT_METHODS`, so +their handler results already bypass serialization and reach the wire +unvalidated; we still register their result rows here for symmetry and so the +maps are consistent if a future SDK adds them to the spec method set. + +## Scope: handshake-era versions only + +The widening + `tasks/*` registration is gated to +`HANDSHAKE_PROTOCOL_VERSIONS` (2025-11-25 and earlier) because those are the +versions where the 2025 SEP-1686 task model actually applies and where the +SDK's registry has the genuine gap we compensate for. + +The 2026-07-28 protocol is intentionally NOT patched here. Tasks left the core +protocol in 2026-07-28 and became the separate `io.modelcontextprotocol/tasks` +extension; `CreateTaskResult` and the `task` field on `CallToolRequestParams` +do not exist in that schema (a task-augmented `tools/call` was replaced by the +mutually-recursive `CallToolResult | InputRequiredResult` result). Injecting the +2025-era `CreateTaskResult` into the 2026 `tools/call` union would assert the +wrong task model onto that protocol, so we leave its rows untouched. + +This module widens the registries IN PLACE (the maps are `MappingProxyType` +views over private dicts, so we reach the backing dict via `gc.get_referents` +and mutate it, which the already-bound default-argument references in +`mcp_types.methods` observe). `install()` is idempotent. + +# TODO(sdk-upstream): remove when mcp>=2.0.0bX wires SEP-1686 into the +# handshake-era method registries. +""" + +from __future__ import annotations + +import gc +from types import MappingProxyType, UnionType + +import mcp_types +from mcp_types import methods as _methods +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS + +# Result type for each task method, keyed by the client request method name. +_TASK_RESULT_TYPES: dict[str, type] = { + "tasks/get": mcp_types.GetTaskResult, + "tasks/result": mcp_types.GetTaskPayloadResult, + "tasks/list": mcp_types.ListTasksResult, + "tasks/cancel": mcp_types.CancelTaskResult, +} + +_installed = False + + +def _backing_dict(proxy: object) -> dict: + """Return the mutable dict a MappingProxyType wraps. + + The `mcp_types.methods` surface maps are `MappingProxyType` views; their + sole dict referent is the backing store the module's functions read through + their default `surface=` arguments. + """ + referents = [r for r in gc.get_referents(proxy) if isinstance(r, dict)] + if len(referents) != 1: + raise RuntimeError( + "expected exactly one backing dict for the method registry proxy, " + f"found {len(referents)}" + ) + return referents[0] + + +def install() -> None: + """Widen the SDK's server-result registry for SEP-1686 task methods. + + Idempotent. Safe to call at import time before any client/server use. + """ + global _installed + if _installed: + return + + if not isinstance(_methods.SERVER_RESULTS, MappingProxyType): + # Registry shape changed upstream; the shim no longer applies. + _installed = True + return + + server_results = _backing_dict(_methods.SERVER_RESULTS) + + # Gate to handshake-era versions only: the 2025 SEP-1686 task model applies + # there, and 2026-07-28 tasks are the separate io.modelcontextprotocol/tasks + # extension (see module docstring) — its rows must stay untouched. + versions_with_tools_call = { + version + for (method, version) in server_results + if method == "tools/call" and version in HANDSHAKE_PROTOCOL_VERSIONS + } + + for version in versions_with_tools_call: + # (a) widen tools/call so a CreateTaskResult validates (task submission). + existing = server_results[("tools/call", version)] + arms = get_union_arms(existing) + if mcp_types.CreateTaskResult not in arms: + server_results[("tools/call", version)] = ( + existing | mcp_types.CreateTaskResult + ) + + # (b) register the tasks/* result rows for the same versions. + for method, result_type in _TASK_RESULT_TYPES.items(): + server_results.setdefault((method, version), result_type) + + _installed = True + + +def get_union_arms(row: type | UnionType) -> tuple[type, ...]: + """Return the member types of a result row, whether a single type or union.""" + if isinstance(row, UnionType): + return tuple(row.__args__) + return (row,) diff --git a/fastmcp_slim/fastmcp/_warnings.py b/fastmcp_slim/fastmcp/_warnings.py deleted file mode 100644 index c63b97a5d..000000000 --- a/fastmcp_slim/fastmcp/_warnings.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Warning types that can be imported without loading FastMCP's exception stack.""" - - -class FastMCPDeprecationWarning(DeprecationWarning): - """Deprecation warning for FastMCP APIs. - - Subclass of DeprecationWarning so that standard warning filters - still apply, but FastMCP can selectively enable its own warnings - without affecting other libraries in the process. - """ diff --git a/fastmcp_slim/fastmcp/apps/app.py b/fastmcp_slim/fastmcp/apps/app.py index 8a4dc64d0..33e606118 100644 --- a/fastmcp_slim/fastmcp/apps/app.py +++ b/fastmcp_slim/fastmcp/apps/app.py @@ -54,20 +54,16 @@ F = TypeVar("F", bound=Callable[..., Any]) def _make_resolver(app_name: str | None = None) -> Any: - """Create a CallTool resolver that addresses peer tools by identity. + """Create a CallTool resolver that prefixes tool names with a hash. - ``app_name`` is the FastMCPApp's name, known at serialization time from - the tool's ``meta["fastmcp"]["app"]`` tag. Serialization happens deep - inside whatever composition the server has, so nothing here can know - what these tools will be *called* by the time the payload reaches a - host. References therefore start out identity-addressed, as - ``<hash>_<local_name>``. + Structurally identical to the old ``___`` resolver — ``app_name`` is + the FastMCPApp's name, known at serialization time from the tool's + ``meta["fastmcp"]["app"]`` tag. The only change is the wire format: + ``<hash>_<local_name>`` instead of ``<app_name>___<local_name>``. - Each FastMCP server rewrites those references on the way out to the - name it lists that tool under, so what a renderer finally receives is - an ordinary tool name (see ``server.providers.prefab_payload``). A - reference no server could resolve keeps this form, which the dispatcher - still routes via ``get_tool_by_hash``. + The dispatcher recognizes the hashed form and routes it via + ``get_tool_by_hash`` which walks the provider tree recursively — + same pattern as ``get_app_tool``. """ from fastmcp.server.providers.addressing import ( hashed_backend_name, @@ -231,17 +227,14 @@ class FastMCPApp(Provider): raise ValueError(f"Cannot determine tool name for {fn!r}") from fastmcp.apps.config import AppConfig, app_config_to_meta_dict - from fastmcp.server.providers.addressing import ( - TOOL_HASH_META_KEY, - hash_tool, - ) + from fastmcp.server.providers.addressing import hash_tool app_config = AppConfig(visibility=visibility) meta: dict[str, Any] = { "ui": app_config_to_meta_dict(app_config), "fastmcp": { "app": self.name, - TOOL_HASH_META_KEY: hash_tool(self.name, resolved_name), + "_tool_hash": hash_tool(self.name, resolved_name), }, } @@ -325,10 +318,7 @@ class FastMCPApp(Provider): def _register(fn: F, tool_name: str | None) -> F: from fastmcp.apps.config import AppConfig, app_config_to_meta_dict - from fastmcp.server.providers.addressing import ( - TOOL_HASH_META_KEY, - hash_tool, - ) + from fastmcp.server.providers.addressing import hash_tool from fastmcp.server.providers.local_provider.decorators.tools import ( PREFAB_RENDERER_URI, ) @@ -344,7 +334,7 @@ class FastMCPApp(Provider): "ui": app_config_to_meta_dict(app_config), "fastmcp": { "app": self.name, - TOOL_HASH_META_KEY: hash_tool(self.name, resolved), + "_tool_hash": hash_tool(self.name, resolved), }, } @@ -383,15 +373,12 @@ class FastMCPApp(Provider): if not isinstance(tool, Tool): tool = Tool._ensure_tool(tool) - from fastmcp.server.providers.addressing import ( - TOOL_HASH_META_KEY, - hash_tool, - ) + from fastmcp.server.providers.addressing import hash_tool meta = dict(tool.meta) if tool.meta else {} fm = meta.setdefault("fastmcp", {}) fm["app"] = self.name - fm[TOOL_HASH_META_KEY] = hash_tool(self.name, tool.name) + fm["_tool_hash"] = hash_tool(self.name, tool.name) ui = meta.setdefault("ui", {}) if "visibility" not in ui: ui["visibility"] = ["app"] diff --git a/fastmcp_slim/fastmcp/apps/config.py b/fastmcp_slim/fastmcp/apps/config.py index 1686d8626..3d89aa382 100644 --- a/fastmcp_slim/fastmcp/apps/config.py +++ b/fastmcp_slim/fastmcp/apps/config.py @@ -11,7 +11,6 @@ from typing import Any, Literal from pydantic import BaseModel, Field -from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type @@ -183,31 +182,3 @@ def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: if isinstance(app, AppConfig): return app.model_dump(by_alias=True, exclude_none=True) return app - - -def is_model_visible(component: FastMCPComponent) -> bool: - """Whether a component may be shown to, or invoked by, the model. - - Visibility is a declaration, and the MCP Apps spec puts the filtering on - the host — so ``tools/list`` carries app-only tools and the host keeps - them from the model. That division only works where a host stands between - the server and the model. - - It does not hold for surfaces a server drives itself. A search result or - a code-mode catalog reaches the model as ordinary tool output, and a - call-tool proxy invokes on a name the model supplies; nothing downstream - can filter either. Those surfaces have to apply the declaration here. - - A component with no ``visibility`` is visible: the field marks the - exception, and the spec's default is both audiences. - """ - meta = component.meta - if not meta: - return True - ui_meta = meta.get("ui") - if not isinstance(ui_meta, dict): - return True - visibility = ui_meta.get("visibility") - if not isinstance(visibility, list): - return True - return "model" in visibility diff --git a/fastmcp_slim/fastmcp/cli/apps_dev.py b/fastmcp_slim/fastmcp/cli/apps_dev.py index 1c7547277..6361db41b 100644 --- a/fastmcp_slim/fastmcp/cli/apps_dev.py +++ b/fastmcp_slim/fastmcp/cli/apps_dev.py @@ -1052,7 +1052,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str: from prefab_ui.components.form import Form from prefab_ui.rx import RESULT, Rx except ImportError: - return "<html><body><p>prefab-ui not installed. Run: pip install 'fastmcp[apps]'</p></body></html>" + return "<html><body><p>prefab-ui not installed. Run: pip install fastmcp[apps]</p></body></html>" if not tools: with Column(gap=4, css_class="p-6 max-w-2xl mx-auto") as view: diff --git a/fastmcp_slim/fastmcp/cli/cli.py b/fastmcp_slim/fastmcp/cli/cli.py index 5513e3119..6c85c8093 100644 --- a/fastmcp_slim/fastmcp/cli/cli.py +++ b/fastmcp_slim/fastmcp/cli/cli.py @@ -23,6 +23,7 @@ from fastmcp.cli.auth import auth_app from fastmcp.cli.client import call_command, discover_command, list_command from fastmcp.cli.generate import generate_cli_command from fastmcp.cli.install import install_app +from fastmcp.cli.tasks import tasks_app from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config from fastmcp.utilities.inspect import ( InspectFormat, @@ -434,7 +435,7 @@ async def run( str | None, cyclopts.Parameter( "--path", - help="The route path for the server (default: /mcp for http transport, /sse for sse transport)", + help="The route path for the server (default: /mcp/ for http transport, /sse/ for sse transport)", ), ] = None, log_level: Annotated[ @@ -1125,6 +1126,9 @@ app.command(project_app) # Add install subcommands using proper Cyclopts pattern app.command(install_app) +# Add tasks subcommand group +app.command(tasks_app) + # Add client query commands app.command(list_command, name="list") app.command(call_command, name="call") diff --git a/fastmcp_slim/fastmcp/cli/discovery.py b/fastmcp_slim/fastmcp/cli/discovery.py index fcc6a49d5..5acd42d61 100644 --- a/fastmcp_slim/fastmcp/cli/discovery.py +++ b/fastmcp_slim/fastmcp/cli/discovery.py @@ -97,39 +97,30 @@ def _parse_mcp_servers( if not servers_dict: return [] - discovered: list[DiscoveredServer] = [] - for name, entry in servers_dict.items(): - if not isinstance(entry, dict): - continue + normalized = { + name: _normalize_server_entry(entry) + for name, entry in servers_dict.items() + if isinstance(entry, dict) + } - normalized = _normalize_server_entry(entry) - try: - config = MCPConfig.from_dict({"mcpServers": {name: normalized}}) - except Exception as exc: - logger.warning( - "Could not parse MCP server %r from %s: %s", - name, - config_path, - exc, - ) - continue + try: + config = MCPConfig.from_dict({"mcpServers": normalized}) + except Exception as exc: + logger.warning("Could not parse MCP servers from %s: %s", config_path, exc) + return [] - discovered.append( - DiscoveredServer( - name=name, - source=source, - config=config.mcpServers[name], - config_path=config_path, - ) + return [ + DiscoveredServer( + name=name, source=source, config=server, config_path=config_path ) - - return discovered + for name, server in config.mcpServers.items() + ] def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]: """Parse an mcpServers-style JSON file into discovered servers.""" try: - text = path.read_text(encoding="utf-8") + text = path.read_text() except OSError as exc: logger.debug("Could not read %s: %s", path, exc) return [] @@ -167,7 +158,7 @@ def _scan_claude_code(start_dir: Path) -> list[DiscoveredServer]: """Scan ``~/.claude.json`` for global and project-scoped MCP servers.""" path = Path.home() / ".claude.json" try: - text = path.read_text(encoding="utf-8") + text = path.read_text() except OSError: return [] @@ -278,7 +269,7 @@ def _scan_goose() -> list[DiscoveredServer]: path = config_dir / "config.yaml" try: - text = path.read_text(encoding="utf-8") + text = path.read_text() except OSError: return [] diff --git a/fastmcp_slim/fastmcp/cli/run.py b/fastmcp_slim/fastmcp/cli/run.py index 0d12f14c2..476c0267d 100644 --- a/fastmcp_slim/fastmcp/cli/run.py +++ b/fastmcp_slim/fastmcp/cli/run.py @@ -97,15 +97,10 @@ def create_client_server(url: str) -> Any: A FastMCP server instance """ try: - # Hand `create_proxy` the URL rather than a pre-built `Client`. A Client - # target is treated as caller-configured and pinned, so its era would be - # fixed at construction — and since `Client` now defaults to `"auto"`, - # that would pin this proxy's upstream to the modern era and break - # handshake-era clients connecting to it (`ping`, server-initiated - # forwarding). Passing the URL lets the proxy mirror each front - # connection's negotiated era instead, so `fastmcp run <URL>` serves - # both eras. - server = create_proxy(url) + import fastmcp + + client = fastmcp.Client(url) + server = create_proxy(client) return server except Exception as e: logger.error(f"Failed to create client for URL {url}: {e}") diff --git a/fastmcp_tasks/fastmcp_tasks/worker_cli.py b/fastmcp_slim/fastmcp/cli/tasks.py similarity index 60% rename from fastmcp_tasks/fastmcp_tasks/worker_cli.py rename to fastmcp_slim/fastmcp/cli/tasks.py index 27dd5be4a..23ddc6e58 100644 --- a/fastmcp_tasks/fastmcp_tasks/worker_cli.py +++ b/fastmcp_slim/fastmcp/cli/tasks.py @@ -1,21 +1,14 @@ """FastMCP tasks CLI for Docket task management.""" -from __future__ import annotations - import asyncio import sys -from typing import TYPE_CHECKING, Annotated +from typing import Annotated import cyclopts from rich.console import Console from fastmcp.utilities.cli import load_and_merge_config from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.tasks import TASKS_EXTENSION_ID -from fastmcp_tasks.settings import DocketSettings - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP logger = get_logger("cli.tasks") console = Console() @@ -26,32 +19,7 @@ tasks_app = cyclopts.App( ) -def resolve_docket_settings(server: FastMCP) -> DocketSettings: - """The effective Docket settings for `server`'s registered tasks extension. - - Reads the *registered* `TasksExtension`'s resolved settings, not the - env-only module-level default: a server that configures - `TasksExtension(url="redis://...")` in code has settings the environment - alone cannot see, and checking those defaults instead would report the - wrong backend (see #4603 review — the CLI checked before the server, and - therefore the extension, was even loaded). - """ - extension = server._extensions.get(TASKS_EXTENSION_ID) - if extension is None: - console.print( - f"[bold red]✗ No tasks extension registered[/bold red]\n\n" - f"[cyan]{server.name}[/cyan] has no `TasksExtension` registered " - "(`mcp.add_extension(TasksExtension())`), so there is nothing for " - "this worker to serve." - ) - sys.exit(1) - from fastmcp_tasks.extension import TasksExtension - - assert isinstance(extension, TasksExtension) - return extension.docket_settings - - -def check_distributed_backend(settings: DocketSettings) -> None: +def check_distributed_backend() -> None: """Check if Docket is configured with a distributed backend. The CLI worker runs as a separate process, so it needs Redis/Valkey @@ -60,8 +28,12 @@ def check_distributed_backend(settings: DocketSettings) -> None: Raises: SystemExit: If using memory:// URL """ + import fastmcp + + docket_url = fastmcp.settings.docket.url + # Check for memory:// URL and provide helpful error - if settings.url.startswith("memory://"): + if docket_url.startswith("memory://"): console.print( "[bold red]✗ In-memory backend not supported by CLI[/bold red]\n\n" "Your Docket configuration uses an in-memory backend (memory://) which\n" @@ -104,6 +76,10 @@ def worker( fastmcp tasks worker server.py fastmcp tasks worker examples/tasks/server.py """ + import fastmcp + + check_distributed_backend() + # Load server to get task functions try: config, _resolved_spec = load_and_merge_config(server_spec) @@ -113,21 +89,15 @@ def worker( # Load the server server = asyncio.run(config.source.load_server()) - # Validate against the server's actual registered extension, not an - # env-only guess — a constructor-configured Redis URL isn't visible - # until the server (and its extension) has loaded. - settings = resolve_docket_settings(server) - check_distributed_backend(settings) - async def run_worker(): """Enter server lifespan and camp forever.""" async with server._lifespan_manager(): console.print( f"[bold green]✓[/bold green] Starting worker for [cyan]{server.name}[/cyan]" ) - console.print(f" Docket: {settings.name}") - console.print(f" Backend: {settings.url}") - console.print(f" Concurrency: {settings.concurrency}") + console.print(f" Docket: {fastmcp.settings.docket.name}") + console.print(f" Backend: {fastmcp.settings.docket.url}") + console.print(f" Concurrency: {fastmcp.settings.docket.concurrency}") # Server's lifespan has started its worker - just camp here forever while True: @@ -138,9 +108,3 @@ def worker( except KeyboardInterrupt: console.print("\n[yellow]Worker stopped[/yellow]") sys.exit(0) - - -if __name__ == "__main__": - # Enables `python -m fastmcp_tasks.worker_cli worker <server>` for running an - # out-of-process worker now that core dropped the `fastmcp tasks` subcommand. - tasks_app() diff --git a/fastmcp_slim/fastmcp/client/__init__.py b/fastmcp_slim/fastmcp/client/__init__.py index 9fc2f350b..d5e65e229 100644 --- a/fastmcp_slim/fastmcp/client/__init__.py +++ b/fastmcp_slim/fastmcp/client/__init__.py @@ -1,12 +1,7 @@ from fastmcp import _install_hints try: - from .auth import ( - BearerAuth, - ClientCredentialsOAuthProvider, - OAuth, - PrivateKeyJWTOAuthProvider, - ) + from .auth import OAuth, BearerAuth from .client import Client from .transports import ( ClientTransport, @@ -26,13 +21,11 @@ except ImportError as exc: __all__ = [ "BearerAuth", "Client", - "ClientCredentialsOAuthProvider", "ClientTransport", "FastMCPTransport", "NodeStdioTransport", "NpxStdioTransport", "OAuth", - "PrivateKeyJWTOAuthProvider", "PythonStdioTransport", "SSETransport", "StdioTransport", diff --git a/fastmcp_slim/fastmcp/client/auth/__init__.py b/fastmcp_slim/fastmcp/client/auth/__init__.py index e706c7f18..6ec3ecf4b 100644 --- a/fastmcp_slim/fastmcp/client/auth/__init__.py +++ b/fastmcp_slim/fastmcp/client/auth/__init__.py @@ -1,17 +1,4 @@ from .bearer import BearerAuth -from .client_credentials import ( - ClientCredentialsOAuthProvider, - PrivateKeyJWTOAuthProvider, - SignedJWTParameters, - static_assertion_provider, -) from .oauth import OAuth -__all__ = [ - "BearerAuth", - "ClientCredentialsOAuthProvider", - "OAuth", - "PrivateKeyJWTOAuthProvider", - "SignedJWTParameters", - "static_assertion_provider", -] +__all__ = ["BearerAuth", "OAuth"] diff --git a/fastmcp_slim/fastmcp/client/auth/client_credentials.py b/fastmcp_slim/fastmcp/client/auth/client_credentials.py deleted file mode 100644 index 6cdcf6f1a..000000000 --- a/fastmcp_slim/fastmcp/client/auth/client_credentials.py +++ /dev/null @@ -1,404 +0,0 @@ -"""Machine-to-machine (M2M) OAuth client authentication for FastMCP. - -These providers authenticate a FastMCP client to a protected MCP server without -a browser, using the OAuth 2.0 ``client_credentials`` grant: - -- `ClientCredentialsOAuthProvider` authenticates with a ``client_id`` and - ``client_secret`` (the common M2M case). -- `PrivateKeyJWTOAuthProvider` authenticates with an RFC 7523 ``private_key_jwt`` - client assertion (workload identity federation, or a locally signed JWT). - -Both are thin wrappers over the MCP SDK's client-credentials providers. Like the -interactive `OAuth` provider, they can be constructed without an ``mcp_url`` and -bound to the server URL automatically when passed to `Client(auth=...)`. -""" - -from __future__ import annotations - -import hashlib -import json -from collections.abc import AsyncGenerator, Awaitable, Callable -from contextvars import ContextVar -from typing import Literal - -import httpx2 -from key_value.aio.protocols import AsyncKeyValue -from key_value.aio.stores.memory import MemoryStore -from mcp.client.auth.extensions.client_credentials import ( - ClientCredentialsOAuthProvider as _SDKClientCredentialsOAuthProvider, -) -from mcp.client.auth.extensions.client_credentials import ( - PrivateKeyJWTOAuthProvider as _SDKPrivateKeyJWTOAuthProvider, -) -from mcp.client.auth.extensions.client_credentials import ( - SignedJWTParameters, - static_assertion_provider, -) -from mcp.client.auth.oauth2 import OAuthContext -from mcp.client.auth.utils import extract_field_from_www_auth -from typing_extensions import override - -from fastmcp.client.auth.oauth import TokenStorageAdapter - -__all__ = [ - "ClientCredentialsOAuthProvider", - "PrivateKeyJWTOAuthProvider", - "SignedJWTParameters", - "static_assertion_provider", -] - -# Whether the auth flow currently being driven is a 403 step-up rather than an -# initial authorization. A ContextVar (not instance state) so it stays scoped to -# the single flow driving it: concurrent flows run in separate tasks and never -# see each other's value, and each flow resets it on exit. -_in_step_up: ContextVar[bool] = ContextVar("fastmcp_m2m_in_step_up", default=False) - - -def _normalize_scopes(scopes: str | list[str] | None) -> str | None: - """Normalize scopes to a space-separated string (or None).""" - if isinstance(scopes, list): - return " ".join(scopes) - return scopes - - -def _cache_namespace(client_id: str, scopes: str | None) -> str: - """Namespace cached tokens by both client identity and requested scopes. - - Two providers that differ in either their ``client_id`` or their requested - scopes must not share cached tokens: a token issued for one client or one - scope set is not interchangeable with another. Hashing a canonical - ``(client_id, scopes)`` pair keeps the namespace unambiguous regardless of the - characters either value contains. - """ - identity = json.dumps([client_id, scopes], separators=(",", ":")) - return hashlib.sha256(identity.encode()).hexdigest() - - -def _resolve_token_storage( - token_storage: AsyncKeyValue | None, - mcp_url: str, - client_id: str, - scopes: str | None, -) -> TokenStorageAdapter: - """Wrap a token store in the FastMCP adapter, defaulting to in-memory. - - Unlike the interactive `OAuth` provider, M2M providers do not warn when using - in-memory storage: re-acquiring a token is a single non-interactive request, - so losing the cache on restart is cheap rather than disruptive. - - The cache is namespaced by client identity and requested scopes so that - providers with different credentials or scope sets can share one store against - the same MCP endpoint without overwriting each other's tokens. - """ - store = token_storage or MemoryStore() - return TokenStorageAdapter( - async_key_value=store, - server_url=mcp_url, - cache_namespace=_cache_namespace(client_id, scopes), - ) - - -def _is_insufficient_scope_challenge(response: httpx2.Response) -> bool: - """True when a response is an RFC 6750 ``insufficient_scope`` step-up challenge.""" - if response.status_code != 403: - return False - return extract_field_from_www_auth(response, "error") == "insufficient_scope" - - -async def _restore_token_expiry(context: OAuthContext) -> None: - """Restore the persisted absolute token expiry after a token is reloaded. - - The inherited initializer reloads the stored token but not its expiry, so a - provider recreated with persistent storage would treat an already-expired - token as still valid. Reading the absolute expiry back keeps `is_token_valid` - honest, prompting a fresh token request when the stored one has expired. - - The restore is skipped unless the reloaded token itself declares an - `expires_in`. A token whose response omitted `expires_in` (``None``) is - non-expiring, and the store may still hold a stale expiry from a previous - token it replaced; applying that would wrongly force a re-exchange. A token - that declares `expires_in=0` is immediately expired and keeps its recorded - expiry, so it is distinguished from an omitted one. - """ - storage = context.storage - tokens = context.current_tokens - if tokens is None or tokens.expires_in is None: - return - if not isinstance(storage, TokenStorageAdapter): - return - expiry = await storage.get_token_expiry() - if expiry is not None: - context.token_expiry_time = expiry - - -async def _drive_flow_tracking_step_up( - flow: AsyncGenerator[httpx2.Request, httpx2.Response], -) -> AsyncGenerator[httpx2.Request, httpx2.Response]: - """Delegate to the inherited auth flow, flagging step-up challenges. - - On a 403 ``insufficient_scope`` the inherited flow unions the challenged scope - with the current one before re-requesting the token. Setting `_in_step_up` - lets `_perform_authorization` leave the accumulated scope in place instead of - re-pinning the caller's explicit scopes over it. The flag is set and reset - inside this generator, so it is scoped to exactly this flow. - """ - token = _in_step_up.set(False) - try: - try: - outgoing = await anext(flow) - except StopAsyncIteration: - return - while True: - response = yield outgoing - if _is_insufficient_scope_challenge(response): - _in_step_up.set(True) - try: - outgoing = await flow.asend(response) - except StopAsyncIteration: - return - finally: - _in_step_up.reset(token) - await flow.aclose() - - -class ClientCredentialsOAuthProvider(_SDKClientCredentialsOAuthProvider): - """OAuth ``client_credentials`` provider using a client ID and secret. - - This is the standard machine-to-machine flow: the client exchanges its - ``client_id`` and ``client_secret`` at the authorization server's token - endpoint for an access token, which is then attached to every request. The - token endpoint is discovered from the MCP server's OAuth metadata, so callers - provide the MCP server URL rather than a raw token endpoint. - - Example: - ```python - from fastmcp import Client - from fastmcp.client.auth import ClientCredentialsOAuthProvider - - auth = ClientCredentialsOAuthProvider( - client_id="my-client-id", - client_secret="my-client-secret", - scopes=["read", "write"], - ) - - async with Client("https://example.com/mcp", auth=auth) as client: - await client.list_tools() - ``` - """ - - _bound: bool - - def __init__( - self, - mcp_url: str | None = None, - *, - client_id: str, - client_secret: str, - scopes: str | list[str] | None = None, - token_endpoint_auth_method: Literal[ - "client_secret_basic", "client_secret_post" - ] = "client_secret_basic", - token_storage: AsyncKeyValue | None = None, - ) -> None: - """Initialize a client_credentials OAuth provider. - - Args: - mcp_url: Full URL to the MCP endpoint (e.g. "https://host/mcp"). - Optional when the provider is passed to `Client(auth=...)`, which - supplies the URL automatically from the transport. - client_id: The pre-registered OAuth client ID. - client_secret: The OAuth client secret. - scopes: OAuth scopes to request, as a space-separated string or a list - of strings. - token_endpoint_auth_method: How client credentials are presented to the - token endpoint. "client_secret_basic" (default) sends them in an - HTTP Basic ``Authorization`` header; "client_secret_post" sends them - in the request body. - token_storage: An AsyncKeyValue-compatible token store. Tokens are kept - in memory if not provided. - """ - self._client_id = client_id - self._client_secret = client_secret - self._scopes = _normalize_scopes(scopes) - self._token_endpoint_auth_method = token_endpoint_auth_method - self._token_storage = token_storage - self._bound = False - - if mcp_url is not None: - self._bind(mcp_url) - - def _bind(self, mcp_url: str) -> None: - """Bind this provider to a specific MCP server URL. - - Called automatically when ``mcp_url`` is provided to ``__init__``, or by the - transport when the provider is used without an explicit URL. - """ - if self._bound: - return - - mcp_url = mcp_url.rstrip("/") - super().__init__( - server_url=mcp_url, - storage=_resolve_token_storage( - self._token_storage, mcp_url, self._client_id, self._scopes - ), - client_id=self._client_id, - client_secret=self._client_secret, - token_endpoint_auth_method=self._token_endpoint_auth_method, - scope=self._scopes, - ) - self._bound = True - - @override - async def _initialize(self) -> None: - await super()._initialize() - await _restore_token_expiry(self.context) - - @override - def async_auth_flow( - self, request: httpx2.Request - ) -> AsyncGenerator[httpx2.Request, httpx2.Response]: - if not self._bound: - raise RuntimeError( - "ClientCredentialsOAuthProvider has no server URL. Either pass " - "mcp_url to the constructor or use it with Client(auth=...), which " - "provides the URL automatically from the transport." - ) - return _drive_flow_tracking_step_up(super().async_auth_flow(request)) - - @override - async def _perform_authorization(self) -> httpx2.Request: - # The inherited flow overwrites client_metadata.scope with the - # server-advertised scopes during 401 handling. Restore the caller's - # explicit scopes so the token request carries what the caller asked for. - # On a step-up the SDK unions the challenged scope with the current one; - # leave that accumulated scope in place instead of clobbering it. - if self._scopes is not None and not _in_step_up.get(): - self.context.client_metadata.scope = self._scopes - return await super()._perform_authorization() - - -class PrivateKeyJWTOAuthProvider(_SDKPrivateKeyJWTOAuthProvider): - """OAuth ``client_credentials`` provider using ``private_key_jwt`` (RFC 7523). - - Instead of a shared client secret, the client authenticates to the token - endpoint with a signed JWT assertion. The ``assertion_provider`` callback - receives the authorization server's issuer identifier (the required JWT - audience) and returns the assertion. Use - `SignedJWTParameters.create_assertion_provider()` to sign locally with a - private key, `static_assertion_provider()` for a pre-built JWT, or supply your - own callback for workload identity federation. - - Example: - ```python - from pathlib import Path - - from fastmcp import Client - from fastmcp.client.auth import ( - PrivateKeyJWTOAuthProvider, - SignedJWTParameters, - ) - - private_key_pem = Path("client-signing-key.pem").read_text() - - jwt_params = SignedJWTParameters( - issuer="my-client-id", - subject="my-client-id", - signing_key=private_key_pem, - ) - auth = PrivateKeyJWTOAuthProvider( - client_id="my-client-id", - assertion_provider=jwt_params.create_assertion_provider(), - ) - - async with Client("https://example.com/mcp", auth=auth) as client: - await client.list_tools() - ``` - """ - - _bound: bool - - def __init__( - self, - mcp_url: str | None = None, - *, - client_id: str, - assertion_provider: Callable[[str], Awaitable[str]], - scopes: str | list[str] | None = None, - token_storage: AsyncKeyValue | None = None, - ) -> None: - """Initialize a private_key_jwt OAuth provider. - - Args: - mcp_url: Full URL to the MCP endpoint (e.g. "https://host/mcp"). - Optional when the provider is passed to `Client(auth=...)`, which - supplies the URL automatically from the transport. - client_id: The OAuth client ID. - assertion_provider: Async callback that receives the authorization - server's issuer identifier (the JWT audience) and returns a signed - JWT assertion. Use `SignedJWTParameters.create_assertion_provider()` - for locally signed JWTs, `static_assertion_provider()` for a - pre-built JWT, or provide your own callback for workload identity - federation. - scopes: OAuth scopes to request, as a space-separated string or a list - of strings. - token_storage: An AsyncKeyValue-compatible token store. Tokens are kept - in memory if not provided. - """ - self._client_id = client_id - self._assertion_provider = assertion_provider - self._scopes = _normalize_scopes(scopes) - self._token_storage = token_storage - self._bound = False - - if mcp_url is not None: - self._bind(mcp_url) - - def _bind(self, mcp_url: str) -> None: - """Bind this provider to a specific MCP server URL. - - Called automatically when ``mcp_url`` is provided to ``__init__``, or by the - transport when the provider is used without an explicit URL. - """ - if self._bound: - return - - mcp_url = mcp_url.rstrip("/") - super().__init__( - server_url=mcp_url, - storage=_resolve_token_storage( - self._token_storage, mcp_url, self._client_id, self._scopes - ), - client_id=self._client_id, - assertion_provider=self._assertion_provider, - scope=self._scopes, - ) - self._bound = True - - @override - async def _initialize(self) -> None: - await super()._initialize() - await _restore_token_expiry(self.context) - - @override - def async_auth_flow( - self, request: httpx2.Request - ) -> AsyncGenerator[httpx2.Request, httpx2.Response]: - if not self._bound: - raise RuntimeError( - "PrivateKeyJWTOAuthProvider has no server URL. Either pass mcp_url " - "to the constructor or use it with Client(auth=...), which provides " - "the URL automatically from the transport." - ) - return _drive_flow_tracking_step_up(super().async_auth_flow(request)) - - @override - async def _perform_authorization(self) -> httpx2.Request: - # The inherited flow overwrites client_metadata.scope with the - # server-advertised scopes during 401 handling. Restore the caller's - # explicit scopes so the token request carries what the caller asked for. - # On a step-up the SDK unions the challenged scope with the current one; - # leave that accumulated scope in place instead of clobbering it. - if self._scopes is not None and not _in_step_up.get(): - self.context.client_metadata.scope = self._scopes - return await super()._perform_authorization() diff --git a/fastmcp_slim/fastmcp/client/auth/oauth.py b/fastmcp_slim/fastmcp/client/auth/oauth.py index 442e6ad33..aca909e4a 100644 --- a/fastmcp_slim/fastmcp/client/auth/oauth.py +++ b/fastmcp_slim/fastmcp/client/auth/oauth.py @@ -87,19 +87,12 @@ async def check_if_auth_required( class TokenStorageAdapter(TokenStorage): _server_url: str - _cache_namespace: str | None _key_value_store: AsyncKeyValue _storage_oauth_token: PydanticAdapter[OAuthToken] _storage_client_info: PydanticAdapter[OAuthClientInformationFull] - def __init__( - self, - async_key_value: AsyncKeyValue, - server_url: str, - cache_namespace: str | None = None, - ): + def __init__(self, async_key_value: AsyncKeyValue, server_url: str): self._server_url = server_url - self._cache_namespace = cache_namespace self._key_value_store = async_key_value self._storage_oauth_token = PydanticAdapter[OAuthToken]( default_collection="mcp-oauth-token", @@ -114,23 +107,14 @@ class TokenStorageAdapter(TokenStorage): raise_on_validation_error=True, ) - def _cache_key_prefix(self) -> str: - # When set, the namespace distinguishes clients that share one store - # against the same server URL (e.g. M2M providers with different - # client_ids). Without it, the prefix is the bare server URL, preserving - # the existing keys used by the interactive OAuth flow. - if self._cache_namespace is not None: - return f"{self._server_url}/{self._cache_namespace}" - return self._server_url - def _get_token_cache_key(self) -> str: - return f"{self._cache_key_prefix()}/tokens" + return f"{self._server_url}/tokens" def _get_client_info_cache_key(self) -> str: - return f"{self._cache_key_prefix()}/client_info" + return f"{self._server_url}/client_info" def _get_token_expiry_cache_key(self) -> str: - return f"{self._cache_key_prefix()}/token_expiry" + return f"{self._server_url}/token_expiry" async def clear(self) -> None: await self._storage_oauth_token.delete(key=self._get_token_cache_key()) @@ -344,6 +328,7 @@ class OAuth(OAuthClientProvider): storage=self.token_storage_adapter, redirect_handler=self.redirect_handler, callback_handler=self.callback_handler, + timeout=self._callback_timeout, client_metadata_url=self._client_metadata_url, ) @@ -430,7 +415,6 @@ class OAuth(OAuthClientProvider): return AuthorizationCodeResult( code=result.code, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] state=result.state, - iss=result.iss, ) except TimeoutError as e: raise TimeoutError( diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py index ae1dde726..6dfaed6cf 100644 --- a/fastmcp_slim/fastmcp/client/client.py +++ b/fastmcp_slim/fastmcp/client/client.py @@ -7,7 +7,8 @@ import hashlib import secrets import ssl import uuid -from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence +import weakref +from collections.abc import Callable, Coroutine from contextlib import AsyncExitStack, asynccontextmanager, suppress from dataclasses import dataclass, field from pathlib import Path @@ -30,25 +31,15 @@ from mcp.client.caching import ( ClientResponseCache, InMemoryResponseCacheStore, ) -from mcp.client.client import ( - _evicting_message_handler, - _fold_extensions, - _synthesize_discover, +from mcp.client.extension import NotificationBinding +from mcp.client.session import ClientRequestContext, MessageHandlerFnT +from mcp_types import ( + GetTaskResult, + TaskStatusNotification, + TaskStatusNotificationParams, ) -from mcp.client.extension import ( - ClaimContext, - ClientExtension, - NotificationBinding, - ResultClaim, -) -from mcp.client.session import ( - ClientRequestContext, - ElicitationFnT, - MessageHandlerFnT, -) -from mcp_types.methods import validate_server_result from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS -from pydantic import AnyUrl, ValidationError +from pydantic import AnyUrl import fastmcp as fastmcp from fastmcp.client.auth.oauth import OAuth @@ -56,7 +47,6 @@ from fastmcp.client.elicitation import ( ElicitationHandler, create_elicitation_callback, ) -from fastmcp.client.extension_hooks import build_internal_client_extensions from fastmcp.client.logging import ( LogHandler, create_log_callback, @@ -66,6 +56,7 @@ from fastmcp.client.messages import MessageHandler, MessageHandlerT from fastmcp.client.mixins import ( ClientPromptsMixin, ClientResourcesMixin, + ClientTaskManagementMixin, ClientToolsMixin, ) from fastmcp.client.progress import ProgressHandler, default_progress_handler @@ -78,6 +69,12 @@ from fastmcp.client.sampling import ( SamplingHandler, create_sampling_callback, ) +from fastmcp.client.tasks import ( + PromptTask, + ResourceTask, + TaskNotificationHandler, + ToolTask, +) from fastmcp.mcp_config import MCPConfig from fastmcp.utilities.exceptions import get_catch_handlers from fastmcp.utilities.logging import get_logger @@ -101,7 +98,6 @@ from .transports import ( StreamableHttpTransport, infer_transport, ) -from .transports.base import TransportOptions __all__ = [ "Client", @@ -124,11 +120,11 @@ CacheableT = TypeVar("CacheableT", bound=mcp_types.CacheableResult) ConnectMode = Literal["legacy", "auto"] | str """How the client negotiates the protocol era at connect time. -- ``"auto"`` (the default): probe ``server/discover`` at the newest modern version and - adopt it, falling back to the initialize handshake for any server that is not positive - evidence of a modern peer (a denylist fallback — see the SDK's ``negotiate_auto``). -- ``"legacy"``: the classic initialize handshake, byte-identical to pre-v4 behavior for - handshake-era servers. Opt into this to force the old handshake. +- ``"legacy"`` (the current default): the classic initialize handshake, byte-identical + to pre-v4 behavior for handshake-era servers. +- ``"auto"``: probe ``server/discover`` at the newest modern version and adopt it, falling + back to the initialize handshake for any server that is not positive evidence of a modern + peer (a denylist fallback — see the SDK's ``negotiate_auto``). - a modern protocol-version string (e.g. ``"2026-07-28"``): adopt that version directly without probing, synthesizing a minimal ``DiscoverResult`` when none is supplied. @@ -136,83 +132,51 @@ The ``str`` arm is only for the version-pin case; ``Client.__init__`` rejects an """ -@asynccontextmanager -async def _conformant_discover_only( - session: ClientSession, -) -> AsyncIterator[None]: - """Hold ``session.send_discover`` to the same wire schema every later reply must meet. +def _synthesize_discover(protocol_version: str) -> mcp_types.DiscoverResult: + """Build a minimal ``DiscoverResult`` for a pinned modern version (no wire probe). - ``negotiate_auto`` accepts a probe that parses as the version-free - ``DiscoverResult``, whose ``resultType``/``ttlMs``/``cacheScope`` all carry - SDK-side defaults. Every request *after* adoption is instead checked against - the strict per-version surface (``validate_server_result``), where those same - three fields are required. A server that answers ``server/discover`` without - them therefore passes the probe and then fails every subsequent call — the - connection is adopted into an era the peer cannot actually serve. - - Closing that gap means judging the probe by the rule that will govern the rest - of the connection. A result that would be rejected later is not positive - evidence of a modern peer, so it is reported as an ordinary probe failure and - ``negotiate_auto`` falls back to the initialize handshake, exactly as it does - for a server with no ``server/discover`` at all. + Mirrors the SDK Client's ``_synthesize_discover``: the version is pinned but the + server identity is unknown, so ``server_info`` is empty. """ - send_discover = session.send_discover + return mcp_types.DiscoverResult( + supported_versions=[protocol_version], + capabilities=mcp_types.ServerCapabilities(), + server_info=mcp_types.Implementation(name="", version=""), + result_type="complete", + ttl_ms=0, + cache_scope="public", + ) - async def _checked_send_discover(version: str) -> dict[str, Any]: - raw = await send_discover(version) - try: - validate_server_result("server/discover", version, raw) - except ValidationError as e: - # Ordered before the ValueError arm below: pydantic's ValidationError - # subclasses ValueError, so a broader clause first would swallow it. - logger.debug( - "server/discover at %s is not %s-conformant (%s); " - "falling back to the initialize handshake", - version, - version, - e, - ) - raise MCPError( - code=mcp_types.INVALID_PARAMS, - message=( - f"server/discover result is not conformant with {version}; " - "treating the server as handshake-era" - ), - ) from e - except (KeyError, ValueError): - # No schema on file for this method/version pair, so there is nothing to - # judge the probe against; leave the verdict to negotiate_auto's parse. - return raw - return raw - # A transport may itself have installed a `send_discover` override, so restore - # whatever was there rather than assuming the class attribute. - had_own = "send_discover" in vars(session) - session.send_discover = _checked_send_discover # ty: ignore[invalid-assignment] - try: - yield - finally: - if had_own: - session.send_discover = send_discover # ty: ignore[invalid-assignment] +def _evicting_message_handler( + cache: ClientResponseCache, user_handler: MessageHandlerFnT | None +) -> MessageHandlerFnT: + """Compose cache eviction over an existing message handler (SEP-2549). + + A server notification (tools/list_changed, resource updates, etc.) evicts the + entries it invalidates *before* the wrapped handler runs, so a downstream + consumer never observes a change while a stale cached listing is still served. + Mirrors the SDK Client's `_evicting_message_handler`, but delegates to FastMCP's + own handler chain rather than clobbering it. Eviction faults are contained: a + cache-store error must never block notification delivery. + """ + + async def handler( + message: Any, + ) -> None: + if isinstance(message, mcp_types.ServerNotification): + try: + await cache.evict_for_notification(message) + except Exception: + logger.exception( + "Response cache eviction failed; the notification is still delivered" + ) + if user_handler is not None: + await user_handler(message) else: - del session.send_discover + await anyio.lowlevel.checkpoint() - -@dataclass -class _FoldedExtensions: - """`Client(extensions=...)` folded into the shapes `ClientSession` consumes. - - `ad` maps each extension identifier to its advertised settings (the SEP-2133 - capability ad), `claims` maps each identifier to its `ResultClaim`s, `bindings` - is the flat list of `NotificationBinding`s the extensions observe, and `by_model` - indexes every claim by its result model so a claimed `tools/call` result can be - routed back to the owning resolver. - """ - - ad: dict[str, dict[str, Any]] - claims: dict[str, tuple[ResultClaim[Any], ...]] - bindings: list[NotificationBinding[Any]] - by_model: dict[type[mcp_types.Result], ResultClaim[Any]] + return handler @dataclass @@ -232,22 +196,6 @@ class ClientSessionState: initialize_result: mcp_types.InitializeResult | None = None -def _connection_failure(exception: BaseException) -> BaseException: - """Present a dead session the same way wherever it is noticed. - - A failed session surfaces from two places: `_connect`, when the connection - never comes up, and `_await_with_session_monitoring`, when the session task - dies while a request is in flight. Which one wins is a matter of timing, so - both report the failure identically — otherwise the same dead backend - reaches callers as either a `RuntimeError` naming the connection or the raw - transport error, depending on the race. Types callers reasonably branch on - are passed through untouched. - """ - if isinstance(exception, httpx2.HTTPStatusError | MCPError): - return exception - return RuntimeError(f"Client failed to connect: {exception}") - - @dataclass class CallToolResult: """Parsed result from a tool call.""" @@ -264,6 +212,7 @@ class Client( ClientResourcesMixin, ClientPromptsMixin, ClientToolsMixin, + ClientTaskManagementMixin, ): """ MCP client that delegates connection management to a Transport instance. @@ -309,13 +258,12 @@ class Client( timeout: Optional timeout for requests (seconds or timedelta) init_timeout: Optional timeout for initial connection (seconds or timedelta). Set to 0 to disable. If None, uses the value in the FastMCP global settings. - mode: Protocol-era negotiation at connect time. `"auto"` (the default) probes + mode: Protocol-era negotiation at connect time. `"legacy"` (the default) runs + the initialize handshake, byte-identical to pre-v4 behavior. `"auto"` probes `server/discover` and negotiates the modern era, denylist-falling-back to the - initialize handshake for any server that is not positive evidence of a modern - peer — safe against a mixed fleet of legacy and modern servers. `"legacy"` - forces the initialize handshake, byte-identical to pre-v4 behavior; opt into it - to pin the old handshake. A modern version string (e.g. `"2026-07-28"`) adopts - that version directly without a probe. + handshake for legacy servers. A modern version string (e.g. `"2026-07-28"`) + adopts that version directly. `mode="auto"` as a future default is a + release-time decision; the conservative `"legacy"` is the default for now. prior_discover: A previously obtained `DiscoverResult` to adopt when `mode` is a version pin, reused instead of synthesizing a minimal one. Ignored otherwise. input_required_max_rounds: Cap on `InputRequiredResult` (SEP-2322) retry rounds @@ -327,19 +275,6 @@ class Client( modern-only, so a cache is inert on legacy connections. A custom `CacheConfig` store requires `target_id`, since FastMCP transports expose no server URL to derive a shared-store identity from. - extensions: Opt-in client extensions (SEP-2133), a sequence of - `mcp.client.extension.ClientExtension` instances. Each contributes its - capability advertisement, its result claims, and its notification bindings, - all of which are threaded into the underlying session. User-supplied - notification bindings compose with FastMCP's internal task-status binding - rather than replacing it. A claimed `call_tool` result is resolved - transparently through the owning extension's resolver. For an advertise-only - entry, use `mcp.client.advertise(identifier, settings)`. - result_claims: Additional `ResultClaim`s (SEP-2133) keyed by the identifier of - an extension already advertised through `extensions`, merged with that - extension's own claims. Rarely needed directly; prefer declaring claims on - the extension itself. Claimed shapes are modern-only and inert on a legacy - connection. Examples: ```python @@ -355,13 +290,6 @@ class Client( ``` """ - #: Whether FastMCP-internal client extensions (e.g. the tasks extension) are - #: folded in automatically at construction. `ProxyClient` overrides this to - #: `False`: a proxy forwards calls and must not advertise task support to its - #: backend, since proxied tools run synchronously (forbidden mode) and the - #: proxy has no path to drive a backend task on the front connection's behalf. - _auto_internal_extensions: bool = True - @overload def __init__(self: Client[T], transport: T, *args: Any, **kwargs: Any) -> None: ... @@ -436,12 +364,10 @@ class Client( client_info: mcp_types.Implementation | None = None, auth: httpx2.Auth | Literal["oauth"] | str | None = None, verify: ssl.SSLContext | bool | str | None = None, - mode: ConnectMode = "auto", + mode: ConnectMode = "legacy", prior_discover: mcp_types.DiscoverResult | None = None, input_required_max_rounds: int = DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, cache: CacheConfig | bool | None = None, - extensions: Sequence[ClientExtension] | None = None, - result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None, ) -> None: self.name = name or self.generate_name() @@ -514,50 +440,29 @@ class Client( cache ) - # The unwrapped base handler (a user handler is preserved as-is). - # Retained so `new()` can rebuild the clone's handler without unwrapping - # the cache-eviction wrapper below. - self._base_message_handler: MessageHandlerFnT | None = message_handler + # The unwrapped base handler (default routes task notifications; a user + # handler is preserved as-is). Retained so `new()` can rebuild the clone's + # handler without unwrapping the cache-eviction wrapper below. + self._base_message_handler: MessageHandlerFnT | None = ( + message_handler or TaskNotificationHandler(self) + ) effective_message_handler = self._base_message_handler if self._response_cache is not None: effective_message_handler = _evicting_message_handler( self._response_cache, effective_message_handler ) - # Opt-in client extensions (SEP-2133) and their result claims. Retained so - # `new()` can rebuild an independent set of session kwargs per clone. - self._extensions_arg = extensions - self._result_claims_arg = result_claims - # Model→claim index the resolution path uses; (re)built by - # `_build_extension_kwargs`. - self._claim_by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = {} - - # Build the elicitation callback up front: it is threaded both into the - # session (to answer server-initiated elicitation) and into the internal - # client extensions (so a task resolver can answer in-task input), and - # `_build_extension_kwargs` — called below — needs it. - self._elicitation_callback: ElicitationFnT | None = ( - create_elicitation_callback(elicitation_handler) - if elicitation_handler is not None - else None - ) - self._session_kwargs: SessionKwargs = { "sampling_callback": None, "list_roots_callback": None, "logging_callback": create_log_callback(log_handler), - # Log delivery is opt-in per request on the modern protocol: the - # session stamps this level into each request's `_meta`, and a - # server sends nothing without it. FastMCP's contract is that a - # client receives everything unless it narrows the level itself, so - # request the most permissive level and let the server's own - # `client_log_level` (and legacy `set_logging_level`) do the - # filtering. Inert on the handshake eras, which have no such opt-in. - "log_level": "debug", "message_handler": effective_message_handler, "read_timeout_seconds": read_timeout_seconds, "client_info": client_info, - **self._build_extension_kwargs(), + # SDK v2 does not carry `notifications/tasks/status` in any protocol + # version's core notification tables, so it is never tee'd to the + # message_handler; a binding routes it to Task objects instead. + "notification_bindings": [self._task_status_binding()], } if roots is not None: @@ -573,8 +478,10 @@ class Client( else mcp_types.SamplingCapability() ) - if self._elicitation_callback is not None: - self._session_kwargs["elicitation_callback"] = self._elicitation_callback + if elicitation_handler is not None: + self._session_kwargs["elicitation_callback"] = create_elicitation_callback( + elicitation_handler + ) # Maximum time to wait for a clean disconnect before giving up. # Normally disconnects complete in <100ms; this is a safety net for @@ -583,7 +490,15 @@ class Client( # Session context management - see class docstring for detailed explanation self._session_state = ClientSessionState() - self._transport_options: TransportOptions | None = None + + # Track task IDs submitted by this client (for list_tasks support) + self._submitted_task_ids: set[str] = set() + + # Registry for routing notifications/tasks/status to Task objects + + self._task_registry: dict[ + str, weakref.ref[ToolTask | PromptTask | ResourceTask] + ] = {} def _build_response_cache( self, cache: CacheConfig | bool | None @@ -657,19 +572,13 @@ class Client( return self._session_state.session - @property - def prior_discover(self) -> mcp_types.DiscoverResult | None: - """The configured result to adopt when `mode` pins a modern version.""" - return self._prior_discover - @property def initialize_result(self) -> mcp_types.InitializeResult | None: """Get the result of the initialization request. `None` on a modern (`server/discover`) connection, which negotiates via a - `DiscoverResult` rather than an `InitializeResult`. Use `protocol_version`, - `server_info`, `server_capabilities`, and `instructions` for era-neutral - access to the negotiated server metadata. + `DiscoverResult` rather than an `InitializeResult`. Use `protocol_version` / + `server_capabilities` for era-neutral access to the negotiated identity. """ return self._session_state.initialize_result @@ -693,27 +602,6 @@ class Client( session = self._session_state.session return session.server_capabilities if session is not None else None - @property - def server_info(self) -> mcp_types.Implementation | None: - """The session's server identity, or `None` when disconnected. - - Populated from whichever negotiation result the era produced (the - `InitializeResult` on legacy, the `DiscoverResult` on modern). A directly - pinned modern version uses a synthesized identity with an empty name. - """ - session = self._session_state.session - return session.server_info if session is not None else None - - @property - def instructions(self) -> str | None: - """The server's instructions, or `None` when absent or disconnected. - - Populated from whichever negotiation result the era produced (the - `InitializeResult` on legacy, the `DiscoverResult` on modern). - """ - session = self._session_state.session - return session.instructions if session is not None else None - def set_roots(self, roots: RootsList | RootsHandler) -> None: """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" self._session_kwargs["list_roots_callback"] = create_roots_callback(roots) @@ -737,12 +625,9 @@ class Client( self, elicitation_callback: ElicitationHandler ) -> None: """Set the elicitation callback for the client.""" - self._elicitation_callback = create_elicitation_callback(elicitation_callback) - self._session_kwargs["elicitation_callback"] = self._elicitation_callback - # Rebuild internal extensions (e.g. the tasks extension) so a background - # task's in-task input is answered through the newly-set handler, not the - # one captured when the client was constructed. - self._session_kwargs.update(self._build_extension_kwargs()) + self._session_kwargs["elicitation_callback"] = create_elicitation_callback( + elicitation_callback + ) def is_connected(self) -> bool: """Check if the client is currently connected.""" @@ -771,7 +656,10 @@ class Client( # Always reset session state so cloned clients start disconnected and do not # share lifecycle state with the original instance. new_client._session_state = ClientSessionState() - new_client._transport_options = self._transport_options + + # Reset mutable task tracking state so new client is independent + new_client._task_registry = {} + new_client._submitted_task_ids = set() # Give the clone its own response cache so cached entries are not shared # across independent sessions, and rebuild the negotiated_version closure @@ -779,10 +667,16 @@ class Client( new_client._response_cache = new_client._build_response_cache(self._cache_arg) # Create a fresh session kwargs dict so the clone doesn't share - # the original's mutable dict; preserve any custom message handler the - # user may have set, re-wrapping with the clone's own cache if one exists. + # the original's mutable dict. Rebind the task notification handler + # to the new client if the default handler is in use; preserve any + # custom message handler the user may have set. new_client._session_kwargs = {**self._session_kwargs} # type: ignore[typeddict-item] + # Recover the unwrapped base handler (never the cache-evicting wrapper): a + # default (TaskNotificationHandler) rebinds to the clone; a user handler is + # preserved. Then re-wrap with the clone's own cache if one exists. base_handler: MessageHandlerFnT | None = self._base_message_handler + if isinstance(base_handler, TaskNotificationHandler) or base_handler is None: + base_handler = TaskNotificationHandler(new_client) new_client._base_message_handler = base_handler if new_client._response_cache is not None: new_client._session_kwargs["message_handler"] = _evicting_message_handler( @@ -790,9 +684,10 @@ class Client( ) else: new_client._session_kwargs["message_handler"] = base_handler - # Rebuild the extension-contributed kwargs (capability ad, result claims, - # notification bindings) so user extensions compose on the clone. - new_client._session_kwargs.update(new_client._build_extension_kwargs()) + # Rebind the task-status notification binding so it routes to the clone. + new_client._session_kwargs["notification_bindings"] = [ + new_client._task_status_binding() + ] new_client.name += f":{secrets.token_hex(2)}" @@ -800,17 +695,10 @@ class Client( @asynccontextmanager async def _context_manager(self): - # Only passed when this client actually wants non-default settings, so an - # ordinary client never sends an argument a transport might not accept. - if self._transport_options is not None: - connection = self.transport.connect_session( - transport_options=self._transport_options, **self._session_kwargs - ) - else: - connection = self.transport.connect_session(**self._session_kwargs) - with catch(get_catch_handlers()): - async with connection as session: + async with self.transport.connect_session( + **self._session_kwargs + ) as session: self._session_state.session = session # Initialize the session if auto_initialize is enabled try: @@ -847,22 +735,14 @@ class Client( else: timeout = normalize_timeout_to_seconds(timeout) - # A legacy-only transport (SSE, a multi-server proxy config) cannot serve - # the modern era; treat "auto" as "legacy" there rather than probing - # server/discover, which some such servers answer but then cannot serve. - effective_mode = self.mode - if effective_mode == "auto" and self.transport.legacy_only: - effective_mode = "legacy" - try: with anyio.fail_after(timeout): - if effective_mode == "legacy": + if self.mode == "legacy": self._session_state.initialize_result = ( await self.session.initialize() ) - elif effective_mode == "auto": - async with _conformant_discover_only(self.session): - await negotiate_auto(self.session) + elif self.mode == "auto": + await negotiate_auto(self.session) # auto may have fallen back to the legacy handshake; surface its # InitializeResult through the existing public property when so. self._session_state.initialize_result = ( @@ -891,9 +771,8 @@ class Client( With `mode="auto"` or a pinned modern version, connect-time negotiation may adopt the modern `server/discover` era, which has no `InitializeResult`; in that case - this method raises. Read `protocol_version`, `server_info`, - `server_capabilities`, and `instructions` instead, or use `mode="legacy"` - when you need the handshake result. + this method raises. Read `protocol_version` / `server_capabilities` instead, or use + `mode="legacy"` (the default) when you need the handshake result. Args: timeout: Optional timeout for the initialization request (seconds or timedelta). @@ -926,9 +805,8 @@ class Client( if self.initialize_result is None: raise RuntimeError( "The client negotiated a modern protocol era (server/discover), which has " - "no InitializeResult. Inspect client.protocol_version, client.server_info, " - "client.server_capabilities, and client.instructions for the metadata " - "available in this mode, or construct the client with mode='legacy'." + "no InitializeResult. Read client.protocol_version / client.server_capabilities " + "instead, or construct the client with mode='legacy'." ) return self.initialize_result @@ -1011,26 +889,18 @@ class Client( raise - session_task = self._session_state.session_task - if not session_task.done() and self._session_state.session is None: - # `_session_runner` sets `ready_event` from its `finally`, - # so a failed connect can wake the wait above before the - # task is marked done. No session means the connect failed, - # so let the task settle and report the failure here rather - # than letting the raw transport error escape on the next - # request. - await asyncio.wait([session_task], timeout=3) - - if session_task.done(): - exception = session_task.exception() + if self._session_state.session_task.done(): + exception = self._session_state.session_task.exception() if exception is None: raise RuntimeError( "Session task completed without exception but connection failed" ) - failure = _connection_failure(exception) - if failure is exception: + # Preserve specific exception types that clients may want to handle + if isinstance(exception, httpx2.HTTPStatusError | MCPError): raise exception - raise failure from exception + raise RuntimeError( + f"Client failed to connect: {exception}" + ) from exception self._session_state.nesting_counter += 1 @@ -1265,87 +1135,50 @@ class Client( max_rounds=self.input_required_max_rounds, ) - def _build_extension_kwargs(self) -> SessionKwargs: - """Session kwargs contributed by `extensions=` / `result_claims=`. + def _handle_task_status_notification( + self, notification: TaskStatusNotification + ) -> None: + """Route task status notification to appropriate Task object. - Folds the user's `ClientExtension` instances into the capability ad, result - claims, and notification bindings the SDK `ClientSession` consumes, then - merges in any explicitly-passed `result_claims`. - - Also rebuilds `self._claim_by_model`, the model→claim index the resolution - path uses to finish a claimed `tools/call` result, covering both the folded - extension claims and the explicit `result_claims` extras. - - FastMCP-internal extensions (e.g. the tasks extension from `fastmcp-tasks`, - registered via `register_internal_client_extension_factory`) are folded in - automatically so an ordinary `Client` transparently drives a server's - background tasks. They lead the fold order; a user extension declaring the - same identifier wins, so the internal one is dropped rather than colliding. + Called when notifications/tasks/status is received from server. + Updates Task object's cache and triggers events/callbacks. """ - user_extensions = list(self._extensions_arg or ()) - user_identifiers = { - identifier - for extension in user_extensions - if (identifier := getattr(extension, "identifier", None)) is not None - } - internal_extensions = ( - [ - extension - for extension in build_internal_client_extensions( - self._elicitation_callback - ) - if extension.identifier not in user_identifiers - ] - if self._auto_internal_extensions - else [] - ) - folded = _fold_extensions([*internal_extensions, *user_extensions]) + self._handle_task_status_params(notification.params) - claims: dict[str, tuple[ResultClaim[Any], ...]] = dict(folded.claims or {}) - by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = dict(folded.by_model) - for identifier, extra in (self._result_claims_arg or {}).items(): - existing = claims.get(identifier, ()) - claims[identifier] = (*existing, *extra) - for claim in extra: - by_model[claim.model] = claim - self._claim_by_model = by_model + def _handle_task_status_params(self, params: TaskStatusNotificationParams) -> None: + """Route task status notification params to the matching Task object.""" + task_id = params.task_id + if not task_id: + return - kwargs: SessionKwargs = { - "notification_bindings": [*(folded.bindings or ())], - } - if folded.ad: - kwargs["extensions"] = folded.ad - if claims: - kwargs["result_claims"] = claims - return kwargs + # Look up task in registry (weakref) + task_ref = self._task_registry.get(task_id) + if task_ref: + task = task_ref() # Dereference weakref + if task: + # Convert notification params to GetTaskResult (they share the same fields via Task) + status = GetTaskResult.model_validate(params.model_dump()) + task._handle_status_notification(status) - async def _resolve_claimed_result( - self, - name: str, - result: mcp_types.Result, - read_timeout_seconds: float | None, - ) -> mcp_types.CallToolResult: - """Finish a claimed `tools/call` result through its owning extension. + def _task_status_binding(self) -> NotificationBinding[TaskStatusNotificationParams]: + """Build a binding routing `notifications/tasks/status` to Task objects. - A modern server may answer `tools/call` with a claimed extension shape - (SEP-2133). The session parses it into the claim's model; this hands that - model to the owning claim's resolver — which may send follow-up requests - through the session — and returns the ordinary `CallToolResult` it - produces. Mirrors the SDK Client's resolution path, including the - output-schema revalidation the direct path performs. + SDK v2 drops notifications whose method is absent from the negotiated + version's core tables before they reach the message_handler; a binding is + the supported channel for observing such vendor notifications. """ - claim = self._claim_by_model[type(result)] - final = await claim.resolve( - result, - ClaimContext( - session=self.session, - tool_name=name, - read_timeout_seconds=read_timeout_seconds, - ), + client_ref = weakref.ref(self) + + async def _handler(params: TaskStatusNotificationParams) -> None: + client = client_ref() + if client is not None: + client._handle_task_status_params(params) + + return NotificationBinding( + method="notifications/tasks/status", + params_type=TaskStatusNotificationParams, + handler=_handler, ) - if not final.is_error: - await self.session.validate_tool_result(name, final) - return final async def close(self): await self._disconnect(force=True) @@ -1388,22 +1221,7 @@ class Client( ) async def set_logging_level(self, level: mcp_types.LoggingLevel) -> None: - """Send a logging/setLevel request. - - Handshake-era servers only. `logging/setLevel` asks the server to - remember a level for the rest of the session, and the 2026-07-28 - protocol has no session to remember it in — the method is absent from - that era's registry. Log *notifications* are unaffected: they ride the - request's own stream, so a server's `ctx.info()` still reaches you. - Filter by level on the receiving side instead, in your `log_handler`. - """ - if self.protocol_version in MODERN_PROTOCOL_VERSIONS: - raise RuntimeError( - "logging/setLevel is not available on MCP 2026-07-28 " - "connections; the method requires per-session server state that " - "the modern protocol does not have. Filter incoming log " - "messages by level in your log_handler instead." - ) + """Send a logging/setLevel request.""" # Deprecated upstream in SDK v2 but deliberately kept per compat directive; # removed with the multi-round-trip follow-up. await self._await_with_session_monitoring( diff --git a/fastmcp_slim/fastmcp/client/extension_hooks.py b/fastmcp_slim/fastmcp/client/extension_hooks.py deleted file mode 100644 index 292efe52e..000000000 --- a/fastmcp_slim/fastmcp/client/extension_hooks.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Registry for FastMCP-internal client extensions (SEP-2133). - -Core ships the client wiring for opt-in extensions but no extension of its own. -A companion package (``fastmcp-tasks``) provides an extension the ``Client`` -folds in automatically once the package is imported — so a caller that uses -tasks (importing ``fastmcp_tasks`` for ``call_tool_task``, or to register the -server extension) gets transparent client task support without passing anything -per ``Client``. The package cannot reach into core's ``Client`` constructor, so -core exposes this hook instead: the package registers a factory on import, and -``Client`` folds the factory's extension in alongside the user's own. - -This mirrors the server-side ``set_background_context_factory`` hook: core -declares the extension point, the tasks package fills it. Task support is -opt-in — with ``fastmcp_tasks`` unimported the registry is empty and ``Client`` -behaves exactly as core alone, so a plain ``from fastmcp import Client`` never -advertises the tasks capability and the server never runs its calls as tasks. - -A factory receives the client's elicitation callback (so a task resolver can -answer in-task input prompts) and returns a ``ClientExtension`` to register, or -``None`` to contribute nothing for this client. -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from mcp.client.extension import ClientExtension - from mcp.client.session import ElicitationFnT - -#: A factory that builds a FastMCP-internal client extension for one ``Client``, -#: given that client's elicitation callback (``None`` when the client has no -#: elicitation handler). -InternalClientExtensionFactory = Callable[ - ["ElicitationFnT | None"], "ClientExtension | None" -] - -_internal_client_extension_factories: list[InternalClientExtensionFactory] = [] - - -def register_internal_client_extension_factory( - factory: InternalClientExtensionFactory, -) -> None: - """Register a factory whose extension every ``Client`` folds in automatically. - - Idempotent: registering the same factory object twice is a no-op, so a - package importing more than once does not double-register. - """ - if factory not in _internal_client_extension_factories: - _internal_client_extension_factories.append(factory) - - -def build_internal_client_extensions( - elicitation_callback: ElicitationFnT | None, -) -> list[ClientExtension]: - """Build the internal extensions to fold into a ``Client`` under construction. - - Each registered factory is invoked with the client's elicitation callback; - factories that return ``None`` contribute nothing. Empty when no companion - package has registered a factory (plain core, or ``fastmcp_tasks`` unimported). - """ - extensions: list[ClientExtension] = [] - for factory in _internal_client_extension_factories: - extension = factory(elicitation_callback) - if extension is not None: - extensions.append(extension) - return extensions diff --git a/fastmcp_slim/fastmcp/client/messages.py b/fastmcp_slim/fastmcp/client/messages.py index 8dfbfd964..7183a8e67 100644 --- a/fastmcp_slim/fastmcp/client/messages.py +++ b/fastmcp_slim/fastmcp/client/messages.py @@ -2,32 +2,57 @@ from typing import TypeAlias import mcp_types from mcp.client.session import MessageHandlerFnT +from mcp.shared.session import RequestResponder -Message: TypeAlias = mcp_types.ServerNotification | Exception +Message: TypeAlias = ( + RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult] + | mcp_types.ServerNotification + | Exception +) MessageHandlerT: TypeAlias = MessageHandlerFnT class MessageHandler: """ - This class is used to handle MCP messages sent to the client: notifications - and transport-level exceptions. Users can override any of the hooks. - - Server-initiated *requests* (ping, sampling, roots) never reach this - handler: the stable MCP SDK v2's `message_handler` contract only delivers - `ServerNotification | Exception`, so a request has no wire path here. - Those are answered through the `Client`'s dedicated callbacks instead — - `sampling_handler=`, `roots=`, and `elicitation_handler=`. + This class is used to handle MCP messages sent to the client. It is used to handle all messages, + requests, notifications, and exceptions. Users can override any of the hooks """ - async def __call__(self, message: mcp_types.ServerNotification | Exception) -> None: + async def __call__( + self, + message: RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult] + | mcp_types.ServerNotification + | Exception, + ) -> None: return await self.dispatch(message) async def dispatch(self, message: Message) -> None: # handle all messages await self.on_message(message) - if isinstance(message, Exception): + # SDK v2 delivers server-to-client requests wrapped in a + # RequestResponder (with the request unwrapped on `.request`) and + # notifications unwrapped (the monolith notification model itself, no + # `.root` wrapper). `ServerNotification`/`ServerRequest` are UnionTypes, + # so they can't appear in class match patterns — branch on the concrete + # models directly. + if isinstance(message, RequestResponder): + # handle all requests + # ty doesn't narrow the generic RequestResponder cleanly here. + await self.on_request(message) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + # handle specific requests + request = message.request + match request: + case mcp_types.PingRequest(): + await self.on_ping(request) + case mcp_types.ListRootsRequest(): + await self.on_list_roots(request) + case mcp_types.CreateMessageRequest(): + await self.on_create_message(request) + + elif isinstance(message, Exception): await self.on_exception(message) else: @@ -54,6 +79,20 @@ class MessageHandler: async def on_message(self, message: Message) -> None: pass + async def on_request( + self, message: RequestResponder[mcp_types.ServerRequest, mcp_types.ClientResult] + ) -> None: + pass + + async def on_ping(self, message: mcp_types.PingRequest) -> None: + pass + + async def on_list_roots(self, message: mcp_types.ListRootsRequest) -> None: + pass + + async def on_create_message(self, message: mcp_types.CreateMessageRequest) -> None: + pass + async def on_notification(self, message: mcp_types.ServerNotification) -> None: pass diff --git a/fastmcp_slim/fastmcp/client/mixins/__init__.py b/fastmcp_slim/fastmcp/client/mixins/__init__.py index f0c8ff85e..323e20991 100644 --- a/fastmcp_slim/fastmcp/client/mixins/__init__.py +++ b/fastmcp_slim/fastmcp/client/mixins/__init__.py @@ -2,10 +2,12 @@ from fastmcp.client.mixins.prompts import ClientPromptsMixin from fastmcp.client.mixins.resources import ClientResourcesMixin +from fastmcp.client.mixins.task_management import ClientTaskManagementMixin from fastmcp.client.mixins.tools import ClientToolsMixin __all__ = [ "ClientPromptsMixin", "ClientResourcesMixin", + "ClientTaskManagementMixin", "ClientToolsMixin", ] diff --git a/fastmcp_slim/fastmcp/client/mixins/prompts.py b/fastmcp_slim/fastmcp/client/mixins/prompts.py index df38292d0..fba8ed3fe 100644 --- a/fastmcp_slim/fastmcp/client/mixins/prompts.py +++ b/fastmcp_slim/fastmcp/client/mixins/prompts.py @@ -2,15 +2,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +import uuid +import weakref +from typing import TYPE_CHECKING, Any, Literal, cast, overload import mcp_types import pydantic_core from mcp.client.caching import CacheMode +from pydantic import RootModel if TYPE_CHECKING: from fastmcp.client.client import Client +from fastmcp.client.tasks import PromptTask from fastmcp.client.telemetry import client_span from fastmcp.telemetry import inject_trace_context from fastmcp.utilities.logging import get_logger @@ -19,6 +23,11 @@ logger = get_logger(__name__) AUTO_PAGINATION_MAX_PAGES = 250 +# Type alias for task response union (SEP-1686 graceful degradation) +PromptTaskResponseUnion = RootModel[ + mcp_types.CreateTaskResult | mcp_types.GetPromptResult +] + class ClientPromptsMixin: """Mixin providing prompt-related methods for Client.""" @@ -183,6 +192,7 @@ class ClientPromptsMixin: ) return result + @overload async def get_prompt( self: Client, name: str, @@ -190,7 +200,33 @@ class ClientPromptsMixin: *, version: str | None = None, meta: dict[str, Any] | None = None, - ) -> mcp_types.GetPromptResult: + task: Literal[False] = False, + ) -> mcp_types.GetPromptResult: ... + + @overload + async def get_prompt( + self: Client, + name: str, + arguments: dict[str, Any] | None = None, + *, + version: str | None = None, + meta: dict[str, Any] | None = None, + task: Literal[True], + task_id: str | None = None, + ttl: int = 60000, + ) -> PromptTask: ... + + async def get_prompt( + self: Client, + name: str, + arguments: dict[str, Any] | None = None, + *, + version: str | None = None, + meta: dict[str, Any] | None = None, + task: bool = False, + task_id: str | None = None, + ttl: int = 60000, + ) -> mcp_types.GetPromptResult | PromptTask: """Retrieve a rendered prompt message list from the server. Args: @@ -198,9 +234,13 @@ class ClientPromptsMixin: arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None. version (str | None, optional): Specific prompt version to get. If None, gets highest version. meta (dict[str, Any] | None): Optional request-level metadata. + task (bool): If True, execute as background task (SEP-1686). Defaults to False. + task_id (str | None): Optional client-provided task ID (auto-generated if not provided). + ttl (int): Time to keep results available in milliseconds (default 60s). Returns: - mcp_types.GetPromptResult: The complete response object. + mcp_types.GetPromptResult | PromptTask: The complete response object if task=False, + or a PromptTask object if task=True. Raises: RuntimeError: If called while the client is not connected. @@ -214,7 +254,94 @@ class ClientPromptsMixin: "version": version, } + if task: + return await self._get_prompt_as_task( + name, arguments, task_id, ttl, meta=request_meta or None + ) + result = await self.get_prompt_mcp( name=name, arguments=arguments, meta=request_meta or None ) return result + + async def _get_prompt_as_task( + self: Client, + name: str, + arguments: dict[str, Any] | None = None, + task_id: str | None = None, + ttl: int = 60000, + meta: dict[str, Any] | None = None, + ) -> PromptTask: + """Get a prompt for background execution (SEP-1686). + + Returns a PromptTask object that handles both background and immediate execution. + + Args: + name: Prompt name to get + arguments: Prompt arguments + task_id: Optional client-provided task ID (ignored, for backward compatibility) + ttl: Time to keep results available in milliseconds (default 60s) + meta: Optional request metadata (e.g., version info) + + Returns: + PromptTask: Future-like object for accessing task status and results + """ + # Per SEP-1686 final spec: client sends only ttl, server generates taskId + # Inject trace context into meta for propagation to server. + # SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not + # the old `RequestParams.Meta` nested model. + propagated_meta = inject_trace_context(meta) + request_meta = cast( + "mcp_types.RequestParamsMeta | None", + propagated_meta if propagated_meta else None, + ) + + # Serialize arguments for MCP protocol + serialized_arguments: dict[str, str] | None = None + if arguments: + serialized_arguments = {} + for key, value in arguments.items(): + if isinstance(value, str): + serialized_arguments[key] = value + else: + serialized_arguments[key] = pydantic_core.to_json(value).decode( + "utf-8" + ) + + # SDK v2: GetPromptRequestParams has no `task` field, so this request + # cannot carry task metadata over the wire and the server graceful- + # degrades to immediate execution (sdk-feedback #3). `ttl` is retained on + # the public API but has no wire representation here. + request = mcp_types.GetPromptRequest( + params=mcp_types.GetPromptRequestParams( + name=name, + arguments=serialized_arguments, + _meta=request_meta, # type: ignore[unknown-argument] # pydantic alias + ) + ) + + # Server returns CreateTaskResult (task accepted) or GetPromptResult (graceful degradation) + wrapped_result = await self._await_with_session_monitoring( + self.session.send_request( + request=request, # type: ignore[arg-type] + result_type=PromptTaskResponseUnion, + ) + ) + raw_result = wrapped_result.root + + if isinstance(raw_result, mcp_types.CreateTaskResult): + # Task was accepted - extract task info from CreateTaskResult + server_task_id = raw_result.task.task_id + self._submitted_task_ids.add(server_task_id) + + task_obj = PromptTask( + self, server_task_id, prompt_name=name, immediate_result=None + ) + self._task_registry[server_task_id] = weakref.ref(task_obj) + return task_obj + else: + # Graceful degradation - server returned GetPromptResult + synthetic_task_id = task_id or str(uuid.uuid4()) + return PromptTask( + self, synthetic_task_id, prompt_name=name, immediate_result=raw_result + ) diff --git a/fastmcp_slim/fastmcp/client/mixins/resources.py b/fastmcp_slim/fastmcp/client/mixins/resources.py index c480b0687..7bbbd84ed 100644 --- a/fastmcp_slim/fastmcp/client/mixins/resources.py +++ b/fastmcp_slim/fastmcp/client/mixins/resources.py @@ -2,15 +2,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +import uuid +import weakref +from typing import TYPE_CHECKING, Any, Literal, cast, overload import mcp_types from mcp.client.caching import CacheMode -from pydantic import AnyUrl +from pydantic import AnyUrl, RootModel if TYPE_CHECKING: from fastmcp.client.client import Client +from fastmcp.client.tasks import ResourceTask from fastmcp.client.telemetry import client_span from fastmcp.telemetry import inject_trace_context from fastmcp.utilities.logging import get_logger @@ -19,6 +22,11 @@ logger = get_logger(__name__) AUTO_PAGINATION_MAX_PAGES = 250 +# Type alias for task response union (SEP-1686 graceful degradation) +ResourceTaskResponseUnion = RootModel[ + mcp_types.CreateTaskResult | mcp_types.ReadResourceResult +] + class ClientResourcesMixin: """Mixin providing resource-related methods for Client.""" @@ -264,23 +272,54 @@ class ClientResourcesMixin: ) return result + @overload async def read_resource( self: Client, uri: AnyUrl | str, *, version: str | None = None, meta: dict[str, Any] | None = None, - ) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: + task: Literal[False] = False, + ) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: ... + + @overload + async def read_resource( + self: Client, + uri: AnyUrl | str, + *, + version: str | None = None, + meta: dict[str, Any] | None = None, + task: Literal[True], + task_id: str | None = None, + ttl: int = 60000, + ) -> ResourceTask: ... + + async def read_resource( + self: Client, + uri: AnyUrl | str, + *, + version: str | None = None, + meta: dict[str, Any] | None = None, + task: bool = False, + task_id: str | None = None, + ttl: int = 60000, + ) -> ( + list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] + | ResourceTask + ): """Read the contents of a resource or resolved template. Args: uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object. version (str | None): Specific version to read. If None, reads highest version. meta (dict[str, Any] | None): Optional request-level metadata. + task (bool): If True, execute as background task (SEP-1686). Defaults to False. + task_id (str | None): Optional client-provided task ID (auto-generated if not provided). + ttl (int): Time to keep results available in milliseconds (default 60s). Returns: - list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: - A list of content objects. + list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] | ResourceTask: + A list of content objects if task=False, or a ResourceTask object if task=True. Raises: RuntimeError: If called while the client is not connected. @@ -294,6 +333,11 @@ class ClientResourcesMixin: "version": version, } + if task: + return await self._read_resource_as_task( + uri, task_id, ttl, meta=request_meta or None + ) + if isinstance(uri, str): try: uri = AnyUrl(uri) # Ensure AnyUrl @@ -303,3 +347,77 @@ class ClientResourcesMixin: ) from e result = await self.read_resource_mcp(uri, meta=request_meta or None) return result.contents + + async def _read_resource_as_task( + self: Client, + uri: AnyUrl | str, + task_id: str | None = None, + ttl: int = 60000, + meta: dict[str, Any] | None = None, + ) -> ResourceTask: + """Read a resource for background execution (SEP-1686). + + Returns a ResourceTask object that handles both background and immediate execution. + + Args: + uri: Resource URI to read + task_id: Optional client-provided task ID (ignored, for backward compatibility) + ttl: Time to keep results available in milliseconds (default 60s) + meta: Optional metadata to pass with the request (e.g., version info) + + Returns: + ResourceTask: Future-like object for accessing task status and results + """ + # Per SEP-1686 final spec: client sends only ttl, server generates taskId + # Inject trace context into meta for propagation to server. + # SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not + # the old `RequestParams.Meta` nested model. + propagated_meta = inject_trace_context(meta) + request_meta = cast( + "mcp_types.RequestParamsMeta | None", + propagated_meta if propagated_meta else None, + ) + + # SDK v2: ReadResourceRequestParams.uri is a plain string, but resources + # are stored under the AnyUrl-normalized form, so normalize to match. + uri_str = str(AnyUrl(uri)) if isinstance(uri, str) else str(uri) + + # SDK v2: ReadResourceRequestParams has no `task` field, so this request + # cannot carry task metadata over the wire and the server graceful- + # degrades to immediate execution (sdk-feedback #3). `ttl` is retained on + # the public API but has no wire representation here. + request = mcp_types.ReadResourceRequest( + params=mcp_types.ReadResourceRequestParams( + uri=uri_str, + _meta=request_meta, # type: ignore[unknown-argument] # pydantic alias + ) + ) + + # Server returns CreateTaskResult (task accepted) or ReadResourceResult (graceful degradation) + wrapped_result = await self._await_with_session_monitoring( + self.session.send_request( + request=request, # type: ignore[arg-type] + result_type=ResourceTaskResponseUnion, + ) + ) + raw_result = wrapped_result.root + + if isinstance(raw_result, mcp_types.CreateTaskResult): + # Task was accepted - extract task info from CreateTaskResult + server_task_id = raw_result.task.task_id + self._submitted_task_ids.add(server_task_id) + + task_obj = ResourceTask( + self, server_task_id, uri=str(uri), immediate_result=None + ) + self._task_registry[server_task_id] = weakref.ref(task_obj) + return task_obj + else: + # Graceful degradation - server returned ReadResourceResult + synthetic_task_id = task_id or str(uuid.uuid4()) + return ResourceTask( + self, + synthetic_task_id, + uri=str(uri), + immediate_result=raw_result.contents, + ) diff --git a/fastmcp_slim/fastmcp/client/mixins/task_management.py b/fastmcp_slim/fastmcp/client/mixins/task_management.py new file mode 100644 index 000000000..634a47435 --- /dev/null +++ b/fastmcp_slim/fastmcp/client/mixins/task_management.py @@ -0,0 +1,232 @@ +"""Task management methods for FastMCP Client.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import mcp_types +from mcp import MCPError +from mcp_types import Result +from pydantic import ConfigDict + +if TYPE_CHECKING: + from fastmcp.client.client import Client +from mcp_types import ( + CancelTaskRequest, + CancelTaskRequestParams, + GetTaskPayloadRequest, + GetTaskPayloadRequestParams, + GetTaskRequest, + GetTaskRequestParams, + GetTaskResult, + ListTasksRequest, + PaginatedRequestParams, +) + +from fastmcp.client.telemetry import client_span +from fastmcp.telemetry import inject_trace_context +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class _RawTaskPayloadResult(Result): + """Permissive result type for `tasks/result` responses. + + Per the v2 spec, a `tasks/result` payload arrives as extra wire fields whose + shape matches the original request's result type (CallToolResult, + GetPromptResult, ReadResourceResult, ...). `GetTaskPayloadResult` is a bare + `Result` that drops those fields on validation, so this subclass retains them + with `extra="allow"`; callers re-parse the resulting dict into the concrete + result type. + """ + + model_config = ConfigDict( + alias_generator=Result.model_config.get("alias_generator"), + populate_by_name=True, + extra="allow", + ) + + +class ClientTaskManagementMixin: + """Mixin providing task management methods for Client.""" + + async def get_task_status(self: Client, task_id: str) -> GetTaskResult: + """Query the status of a background task. + + Sends a 'tasks/get' MCP protocol request over the existing transport. + + Args: + task_id: The task ID returned from call_tool_as_task + + Returns: + GetTaskResult: Status information including taskId, status, pollInterval, etc. + + Raises: + RuntimeError: If client not connected + MCPError: If the request results in a TimeoutError | JSONRPCError + """ + with client_span( + "tasks/get", + "tasks/get", + task_id, + session_id=self.transport.get_session_id(), + ): + request_meta = cast( + "mcp_types.RequestParamsMeta | None", inject_trace_context() + ) + request = GetTaskRequest( + params=GetTaskRequestParams( + task_id=task_id, + _meta=request_meta, # type: ignore[unknown-argument] + ) + ) + return await self._await_with_session_monitoring( + self.session.send_request( + request=request, # type: ignore[arg-type] + result_type=GetTaskResult, + ) + ) + + async def get_task_result(self: Client, task_id: str) -> Any: + """Retrieve the raw result of a completed background task. + + Sends a 'tasks/result' MCP protocol request over the existing transport. + Returns the raw result - callers should parse it appropriately. + + Args: + task_id: The task ID returned from call_tool_as_task + + Returns: + Any: The raw result (could be tool, prompt, or resource result) + + Raises: + RuntimeError: If client not connected, task not found, or task failed + MCPError: If the request results in a TimeoutError | JSONRPCError + """ + with client_span( + "tasks/result", + "tasks/result", + task_id, + session_id=self.transport.get_session_id(), + ): + request_meta = cast( + "mcp_types.RequestParamsMeta | None", inject_trace_context() + ) + request = GetTaskPayloadRequest( + params=GetTaskPayloadRequestParams( + task_id=task_id, + _meta=request_meta, # type: ignore[unknown-argument] + ) + ) + # Return raw result - Task classes handle type-specific parsing + result = await self._await_with_session_monitoring( + self.session.send_request( + request=request, # type: ignore[arg-type] + result_type=_RawTaskPayloadResult, + ) + ) + # Return as dict for compatibility with Task class parsing. The payload + # fields (content, structuredContent, messages, contents, ...) survive + # via the permissive result type's extra="allow". + return result.model_dump(exclude_none=True, by_alias=True) + + async def list_tasks( + self: Client, + cursor: str | None = None, + limit: int = 50, + ) -> dict[str, Any]: + """List background tasks. + + Sends a 'tasks/list' MCP protocol request to the server. If the server + returns an empty list (indicating client-side tracking), falls back to + querying status for locally tracked task IDs. + + Args: + cursor: Optional pagination cursor + limit: Maximum number of tasks to return (default 50) + + Returns: + dict: Response with structure: + - tasks: List of task status dicts with taskId, status, etc. + - nextCursor: Optional cursor for next page + + Raises: + RuntimeError: If client not connected + MCPError: If the request results in a TimeoutError | JSONRPCError + """ + with client_span( + "tasks/list", + "tasks/list", + "", + session_id=self.transport.get_session_id(), + ): + request_meta = cast( + "mcp_types.RequestParamsMeta | None", inject_trace_context() + ) + + # Send protocol request + params = PaginatedRequestParams.model_validate( + {"cursor": cursor, "limit": limit, "_meta": request_meta} + ) + request = ListTasksRequest(params=params) + server_response = await self._await_with_session_monitoring( + self.session.send_request( + request=request, # type: ignore[invalid-argument-type] + result_type=mcp_types.ListTasksResult, + ) + ) + + # If server returned tasks, use those + if server_response.tasks: + return server_response.model_dump(by_alias=True) + + # Server returned empty - fall back to client-side tracking + tasks = [] + for task_id in list(self._submitted_task_ids)[:limit]: + try: + status = await self.get_task_status(task_id) + tasks.append(status.model_dump(by_alias=True)) + except MCPError: + # Task may have expired or been deleted, skip it + continue + + return {"tasks": tasks, "nextCursor": None} + + async def cancel_task(self: Client, task_id: str) -> mcp_types.CancelTaskResult: + """Cancel a task, transitioning it to cancelled state. + + Sends a 'tasks/cancel' MCP protocol request. Task will halt execution + and transition to cancelled state. + + Args: + task_id: The task ID to cancel + + Returns: + CancelTaskResult: The task status showing cancelled state + + Raises: + RuntimeError: If task doesn't exist + MCPError: If the request results in a TimeoutError | JSONRPCError + """ + with client_span( + "tasks/cancel", + "tasks/cancel", + task_id, + session_id=self.transport.get_session_id(), + ): + request_meta = cast( + "mcp_types.RequestParamsMeta | None", inject_trace_context() + ) + request = CancelTaskRequest( + params=CancelTaskRequestParams( + task_id=task_id, + _meta=request_meta, # type: ignore[unknown-argument] + ) + ) + return await self._await_with_session_monitoring( + self.session.send_request( + request=request, # type: ignore[invalid-argument-type] + result_type=mcp_types.CancelTaskResult, + ) + ) diff --git a/fastmcp_slim/fastmcp/client/mixins/tools.py b/fastmcp_slim/fastmcp/client/mixins/tools.py index 40db1f21c..6e0a0de11 100644 --- a/fastmcp_slim/fastmcp/client/mixins/tools.py +++ b/fastmcp_slim/fastmcp/client/mixins/tools.py @@ -2,17 +2,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +import uuid +import weakref +from typing import TYPE_CHECKING, Any, Literal, cast, overload import mcp_types from mcp.client.caching import CacheMode from opentelemetry.trace import Status, StatusCode +from pydantic import RootModel if TYPE_CHECKING: import datetime from fastmcp.client.client import CallToolResult, Client from fastmcp.client.progress import ProgressHandler +from fastmcp.client.tasks import ToolTask from fastmcp.client.telemetry import client_span from fastmcp.exceptions import ToolError from fastmcp.telemetry import inject_trace_context @@ -25,6 +29,9 @@ logger = get_logger(__name__) AUTO_PAGINATION_MAX_PAGES = 250 +# Type alias for task response union (SEP-1686 graceful degradation) +ToolTaskResponseUnion = RootModel[mcp_types.CreateTaskResult | mcp_types.CallToolResult] + class ClientToolsMixin: """Mixin providing tool-related methods for Client.""" @@ -188,20 +195,10 @@ class ClientToolsMixin: read_timeout_seconds = normalize_timeout_to_seconds(timeout) progress_callback = progress_handler or self._progress_handler - # Only opt into claimed results (SEP-2133) when this client registered - # an extension that claims one; otherwise keep the SDK's default, which - # surfaces an unexpected claimed result as an error rather than parsing - # a shape we have no resolver for. - has_claims = bool(self._claim_by_model) - async def _retry( input_responses: mcp_types.InputResponses | None, request_state: str | None, - ) -> ( - mcp_types.CallToolResult - | mcp_types.InputRequiredResult - | mcp_types.Result - ): + ) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult: return await self.session.call_tool( name=name, arguments=arguments, @@ -211,26 +208,12 @@ class ClientToolsMixin: input_responses=input_responses, request_state=request_state, allow_input_required=True, - allow_claimed=has_claims, ) first = await self._await_with_session_monitoring(_retry(None, None)) - driven = await self._await_with_session_monitoring( + result = await self._await_with_session_monitoring( self._drive_input_required(first, _retry) ) - if isinstance(driven, mcp_types.CallToolResult): - result = driven - else: - # A claimed extension result (SEP-2133): resolve it through the - # owning extension's resolver into an ordinary CallToolResult. - # Resolution issues further session requests of its own (result - # validation lists tools; a resolver may make more), so it needs - # the same session monitoring as the calls above — otherwise a - # transport-level failure can kill the session runner while this - # await waits forever. - result = await self._await_with_session_monitoring( - self._resolve_claimed_result(name, driven, read_timeout_seconds) - ) # Reflect tool-level errors on the span so callers see ERROR # status even though the MCP protocol call itself succeeded. @@ -271,6 +254,7 @@ class ClientToolsMixin: raise_on_error=raise_on_error, ) + @overload async def call_tool( self: Client, name: str, @@ -281,7 +265,39 @@ class ClientToolsMixin: progress_handler: ProgressHandler | None = None, raise_on_error: bool = True, meta: dict[str, Any] | None = None, - ) -> CallToolResult: + task: Literal[False] = False, + ) -> CallToolResult: ... + + @overload + async def call_tool( + self: Client, + name: str, + arguments: dict[str, Any] | None = None, + *, + version: str | None = None, + timeout: datetime.timedelta | float | int | None = None, + progress_handler: ProgressHandler | None = None, + raise_on_error: bool = True, + meta: dict[str, Any] | None = None, + task: Literal[True], + task_id: str | None = None, + ttl: int = 60000, + ) -> ToolTask: ... + + async def call_tool( + self: Client, + name: str, + arguments: dict[str, Any] | None = None, + *, + version: str | None = None, + timeout: datetime.timedelta | float | int | None = None, + progress_handler: ProgressHandler | None = None, + raise_on_error: bool = True, + meta: dict[str, Any] | None = None, + task: bool = False, + task_id: str | None = None, + ttl: int = 60000, + ) -> CallToolResult | ToolTask: """Call a tool on the server. Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error. @@ -297,11 +313,15 @@ class ClientToolsMixin: This is useful for passing contextual information (like user IDs, trace IDs, or preferences) that shouldn't be tool arguments but may influence server-side processing. The server can access this via `context.request_context.meta`. Defaults to None. + task (bool): If True, execute as background task (SEP-1686). Defaults to False. + task_id (str | None): Optional client-provided task ID (auto-generated if not provided). + ttl (int): Time to keep results available in milliseconds (default 60s). Returns: - CallToolResult: The content returned by the tool. If the tool returns - structured outputs, they are returned as a dataclass (if an output - schema is available) or a dictionary; otherwise, a list of content + CallToolResult | ToolTask: The content returned by the tool if task=False, + or a ToolTask object if task=True. If the tool returns structured + outputs, they are returned as a dataclass (if an output schema + is available) or a dictionary; otherwise, a list of content blocks is returned. Note: to receive both structured and unstructured outputs, use call_tool_mcp instead and access the raw result object. @@ -319,6 +339,16 @@ class ClientToolsMixin: "version": version, } + if task: + return await self._call_tool_as_task( + name, + arguments, + task_id, + ttl, + raise_on_error=raise_on_error, + meta=request_meta or None, + ) + result = await self.call_tool_mcp( name=name, arguments=arguments or {}, @@ -330,6 +360,85 @@ class ClientToolsMixin: name, result, raise_on_error=raise_on_error ) + async def _call_tool_as_task( + self: Client, + name: str, + arguments: dict[str, Any] | None = None, + task_id: str | None = None, + ttl: int = 60000, + raise_on_error: bool = True, + meta: dict[str, Any] | None = None, + ) -> ToolTask: + """Call a tool for background execution (SEP-1686). + + Returns a ToolTask object that handles both background and immediate execution. + If the server accepts background execution, ToolTask will poll for results. + If the server declines (graceful degradation), ToolTask wraps the immediate result. + + Args: + name: Tool name to call + arguments: Tool arguments + task_id: Optional client-provided task ID (ignored, for backward compatibility) + ttl: Time to keep results available in milliseconds (default 60s) + raise_on_error: Whether task.result() should raise ToolError on errors + meta: Optional request metadata (e.g., version info) + + Returns: + ToolTask: Future-like object for accessing task status and results + """ + # Per SEP-1686 final spec: client sends only ttl, server generates taskId + # Inject trace context into meta for propagation to server + propagated_meta = inject_trace_context(meta) + # SDK v2: request `_meta` is `RequestParamsMeta` (a TypedDict), not the + # old `RequestParams.Meta` nested model. + request_meta = cast(mcp_types.RequestParamsMeta | None, propagated_meta) + + # Build request with task metadata + request = mcp_types.CallToolRequest( + params=mcp_types.CallToolRequestParams( + name=name, + arguments=arguments or {}, + task=mcp_types.TaskMetadata(ttl=ttl), + _meta=request_meta, # type: ignore[unknown-argument] # pydantic alias + ) + ) + + # Server returns CreateTaskResult (task accepted) or CallToolResult (graceful degradation) + # Use RootModel with Union to handle both response types (SDK calls model_validate) + wrapped_result = await self._await_with_session_monitoring( + self.session.send_request( + request=request, # type: ignore[arg-type] + result_type=ToolTaskResponseUnion, + ) + ) + raw_result = wrapped_result.root + + if isinstance(raw_result, mcp_types.CreateTaskResult): + # Task was accepted - extract task info from CreateTaskResult + server_task_id = raw_result.task.task_id + self._submitted_task_ids.add(server_task_id) + + task_obj = ToolTask( + self, + server_task_id, + tool_name=name, + immediate_result=None, + raise_on_error=raise_on_error, + ) + self._task_registry[server_task_id] = weakref.ref(task_obj) + return task_obj + else: + # Graceful degradation - server returned CallToolResult + parsed_result = await self._parse_call_tool_result(name, raw_result) + synthetic_task_id = task_id or str(uuid.uuid4()) + return ToolTask( + self, + synthetic_task_id, + tool_name=name, + immediate_result=parsed_result, + raise_on_error=raise_on_error, + ) + async def _parse_call_tool_result( name: str, diff --git a/fastmcp_slim/fastmcp/client/oauth_callback.py b/fastmcp_slim/fastmcp/client/oauth_callback.py index ade1764dc..3d7a74223 100644 --- a/fastmcp_slim/fastmcp/client/oauth_callback.py +++ b/fastmcp_slim/fastmcp/client/oauth_callback.py @@ -82,11 +82,6 @@ class CallbackResponse: state: str | None = None error: str | None = None error_description: str | None = None - # RFC 9207: the authorization server's issuer identifier, sent on both - # success and error redirects once OAuthProxy advertises - # `authorization_response_iss_parameter_supported`. Must be captured - # here or `from_dict`'s annotation filter silently drops it. - iss: str | None = None @classmethod def from_dict(cls, data: dict[str, str]) -> CallbackResponse: @@ -103,12 +98,6 @@ class OAuthCallbackResult: code: str | None = None state: str | None = None error: Exception | None = None - # RFC 9207 issuer identifier, captured on both success and error - # callbacks. The MCP SDK's `validate_authorization_response_iss` only - # consumes this on the success path (via `AuthorizationCodeResult.iss`), - # but it is stored unconditionally here so the error path never silently - # drops it either. - iss: str | None = None def create_oauth_callback_server( @@ -138,7 +127,6 @@ def create_oauth_callback_server( code: str | None = None, state: str | None = None, error: Exception | None = None, - iss: str | None = None, ) -> None: """Store the first callback result and ignore subsequent requests.""" if result_container is None or result_ready is None or result_ready.is_set(): @@ -147,7 +135,6 @@ def create_oauth_callback_server( result_container.code = code result_container.state = state result_container.error = error - result_container.iss = iss result_ready.set() async def callback_handler(request: Request): @@ -164,13 +151,8 @@ def create_oauth_callback_server( else: user_message = f"Authorization failed: {error_desc}" - # Store error and signal completion if result tracking provided. - # RFC 9207: `iss` is captured here too, even though the callback - # ultimately raises instead of returning a result, so it isn't - # silently dropped for callers that want to inspect it. - store_result_once( - error=RuntimeError(user_message), iss=callback_response.iss - ) + # Store error and signal completion if result tracking provided + store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( @@ -184,9 +166,7 @@ def create_oauth_callback_server( user_message = "No authorization code was received from the server." # Store error and signal completion if result tracking provided - store_result_once( - error=RuntimeError(user_message), iss=callback_response.iss - ) + store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( @@ -203,9 +183,7 @@ def create_oauth_callback_server( ) # Store error and signal completion if result tracking provided - store_result_once( - error=RuntimeError(user_message), iss=callback_response.iss - ) + store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( @@ -218,7 +196,6 @@ def create_oauth_callback_server( # Success case - store result and signal completion if result tracking provided store_result_once( code=callback_response.code, - iss=callback_response.iss, state=callback_response.state, ) diff --git a/fastmcp_slim/fastmcp/client/progress.py b/fastmcp_slim/fastmcp/client/progress.py index 72392305c..826d2cb99 100644 --- a/fastmcp_slim/fastmcp/client/progress.py +++ b/fastmcp_slim/fastmcp/client/progress.py @@ -1,6 +1,6 @@ from typing import TypeAlias -from mcp.shared.dispatcher import ProgressFnT +from mcp.shared.session import ProgressFnT from fastmcp.utilities.logging import get_logger diff --git a/fastmcp_slim/fastmcp/client/roots.py b/fastmcp_slim/fastmcp/client/roots.py index 7db4b9d60..cb655c1fb 100644 --- a/fastmcp_slim/fastmcp/client/roots.py +++ b/fastmcp_slim/fastmcp/client/roots.py @@ -37,7 +37,7 @@ def create_roots_callback( if isinstance(handler, list): # TODO(ty): remove when ty supports isinstance union narrowing return _create_roots_callback_from_roots(handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - elif callable(handler): + elif inspect.isfunction(handler): return _create_roots_callback_from_fn(handler) else: raise ValueError(f"Invalid roots handler: {handler}") diff --git a/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py b/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py index f72c4c344..88eb132ee 100644 --- a/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py +++ b/fastmcp_slim/fastmcp/client/sampling/handlers/anthropic.py @@ -41,7 +41,7 @@ try: except ImportError as e: raise ImportError( "The `anthropic` package is not installed. " - "Install it with `pip install 'fastmcp-slim[anthropic]'` or add `anthropic` to your dependencies." + "Install it with `pip install fastmcp-slim[anthropic]` or add `anthropic` to your dependencies." ) from e __all__ = ["AnthropicSamplingHandler"] @@ -75,7 +75,7 @@ class AnthropicSamplingHandler: Example: ```python from anthropic import AsyncAnthropic - from fastmcp import Client + from fastmcp import FastMCP from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler handler = AnthropicSamplingHandler( @@ -83,9 +83,7 @@ class AnthropicSamplingHandler: client=AsyncAnthropic(), ) - # Answers a handshake-era server's push request and a modern server's - # input-required round alike. - client = Client("https://example.com/mcp", sampling_handler=handler) + server = FastMCP(sampling_handler=handler) ``` """ diff --git a/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py b/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py index 23a6fa033..12d6064e6 100644 --- a/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py +++ b/fastmcp_slim/fastmcp/client/sampling/handlers/google_genai.py @@ -28,7 +28,7 @@ try: except ImportError as e: raise ImportError( "The `google-genai` package is not installed. " - "Install it with `pip install 'fastmcp-slim[gemini]'` or add `google-genai` " + "Install it with `pip install fastmcp-slim[gemini]` or add `google-genai` " "to your dependencies." ) from e @@ -60,20 +60,18 @@ class GoogleGenaiSamplingHandler: Example: ```python - from google.genai import Client as GoogleGenaiClient - from fastmcp import Client as FastMCPClient + from google.genai import Client + from fastmcp import FastMCP from fastmcp.client.sampling.handlers.google_genai import ( GoogleGenaiSamplingHandler, ) handler = GoogleGenaiSamplingHandler( default_model="gemini-2.0-flash", - client=GoogleGenaiClient(), + client=Client(), ) - # Answers a handshake-era server's push request and a modern server's - # input-required round alike. - client = FastMCPClient("https://example.com/mcp", sampling_handler=handler) + server = FastMCP(sampling_handler=handler) ``` """ diff --git a/fastmcp_slim/fastmcp/client/tasks.py b/fastmcp_slim/fastmcp/client/tasks.py new file mode 100644 index 000000000..ee3958f16 --- /dev/null +++ b/fastmcp_slim/fastmcp/client/tasks.py @@ -0,0 +1,585 @@ +"""SEP-1686 client Task classes.""" + +from __future__ import annotations + +import abc +import asyncio +import inspect +import time +import weakref +from collections.abc import Awaitable, Callable +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Generic, TypeVar + +import mcp_types +from mcp_types import GetTaskResult, TaskStatusNotification + +from fastmcp.client.messages import Message, MessageHandler +from fastmcp.exceptions import ToolError +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + +if TYPE_CHECKING: + from fastmcp.client.client import CallToolResult, Client + + +class TaskNotificationHandler(MessageHandler): + """MessageHandler that routes task status notifications to Task objects.""" + + def __init__(self, client: Client): + super().__init__() + self._client_ref: weakref.ref[Client] = weakref.ref(client) + + async def dispatch(self, message: Message) -> None: + """Dispatch messages, including task status notifications.""" + # SDK v2 delivers notifications unwrapped (no `.root` wrapper). + if isinstance(message, TaskStatusNotification): + client = self._client_ref() + if client: + client._handle_task_status_notification(message) + + await super().dispatch(message) + + +TaskResultT = TypeVar("TaskResultT") + + +class Task(abc.ABC, Generic[TaskResultT]): + """ + Abstract base class for MCP background tasks (SEP-1686). + + Provides a uniform API whether the server accepts background execution + or executes synchronously (graceful degradation per SEP-1686). + + Subclasses: + - ToolTask: For tool calls (result type: CallToolResult) + - PromptTask: For prompts (future, result type: GetPromptResult) + - ResourceTask: For resources (future, result type: ReadResourceResult) + """ + + def __init__( + self, + client: Client, + task_id: str, + immediate_result: TaskResultT | None = None, + ): + """ + Create a Task wrapper. + + Args: + client: The FastMCP client + task_id: The task identifier + immediate_result: If server executed synchronously, the immediate result + """ + self._client = client + self._task_id = task_id + self._immediate_result = immediate_result + self._is_immediate = immediate_result is not None + + # Notification-based optimization (SEP-1686 notifications/tasks/status) + self._status_cache: GetTaskResult | None = None + self._status_event: asyncio.Event | None = None # Lazy init + self._status_callbacks: list[ + Callable[[GetTaskResult], None | Awaitable[None]] + ] = [] + self._cached_result: TaskResultT | None = None + + def _check_client_connected(self) -> None: + """Validate that client context is still active. + + Raises: + RuntimeError: If accessed outside client context (unless immediate) + """ + if self._is_immediate: + return # Already resolved, no client needed + + try: + _ = self._client.session + except RuntimeError as e: + raise RuntimeError( + "Cannot access task results outside client context. " + "Task futures must be used within 'async with client:' block." + ) from e + + @property + def task_id(self) -> str: + """Get the task ID.""" + return self._task_id + + @property + def returned_immediately(self) -> bool: + """Check if server executed the task immediately. + + Returns: + True if server executed synchronously (graceful degradation or no task support) + False if server accepted background execution + """ + return self._is_immediate + + def _handle_status_notification(self, status: GetTaskResult) -> None: + """Process incoming notifications/tasks/status (internal). + + Called by Client when a notification is received for this task. + Updates cache, triggers events, and invokes user callbacks. + + Args: + status: Task status from notification + """ + # Update cache for next status() call + self._status_cache = status + + # Wake up any wait() calls + if self._status_event is not None: + self._status_event.set() + + # Invoke user callbacks + for callback in self._status_callbacks: + try: + result = callback(status) + if inspect.isawaitable(result): + # Fire and forget async callbacks + asyncio.create_task(result) # type: ignore[arg-type] # noqa: RUF006 # ty:ignore[invalid-argument-type] + except Exception as e: + logger.warning(f"Task callback error: {e}", exc_info=True) + + def on_status_change( + self, + callback: Callable[[GetTaskResult], None | Awaitable[None]], + ) -> None: + """Register callback for status change notifications. + + The callback will be invoked when a notifications/tasks/status is received + for this task (optional server feature per SEP-1686 lines 436-444). + + Supports both sync and async callbacks (auto-detected). + + Args: + callback: Function to call with GetTaskResult when status changes. + Can return None (sync) or Awaitable[None] (async). + + Example: + >>> task = await client.call_tool("slow_operation", {}, task=True) + >>> + >>> def on_update(status: GetTaskResult): + ... print(f"Task {status.task_id} is now {status.status}") + >>> + >>> task.on_status_change(on_update) + >>> result = await task # Callback fires when status changes + """ + self._status_callbacks.append(callback) + + async def status(self) -> GetTaskResult: + """Get current task status. + + If server executed immediately, returns synthetic completed status. + Otherwise queries the server for current status. + """ + self._check_client_connected() + + if self._is_immediate: + # Return synthetic completed status. SDK v2 types the task + # timestamps as ISO 8601 strings. + now = datetime.now(timezone.utc).isoformat() + return GetTaskResult( + task_id=self._task_id, + status="completed", + created_at=now, + last_updated_at=now, + ttl=None, + poll_interval=1000, + ) + + # Return cached status if available (from notification) + if self._status_cache is not None: + cached = self._status_cache + # Don't clear cache - keep it for next call + return cached + + # Query server and cache the result + self._status_cache = await self._client.get_task_status(self._task_id) + return self._status_cache + + @abc.abstractmethod + async def result(self) -> TaskResultT: + """Wait for and return the task result. + + Must be implemented by subclasses to return the appropriate result type. + """ + ... + + async def wait( + self, *, state: str | None = None, timeout: float = 300.0 + ) -> GetTaskResult: + """Wait for task to reach a specific state or complete. + + Uses event-based waiting when notifications are available (fast), + with fallback to polling (reliable). Optimally wakes up immediately + on status changes when server sends notifications/tasks/status. + + Args: + state: Desired state ('working', 'input_required', 'completed', 'failed', 'cancelled'). + If None, waits until the task exits the 'working' state (completed, failed, cancelled, input_required, etc.) + timeout: Maximum time to wait in seconds + + Returns: + GetTaskResult: Final task status + + Raises: + TimeoutError: If desired state not reached within timeout + """ + self._check_client_connected() + + if self._is_immediate: + # Already done + return await self.status() + + # Initialize event for notification wake-ups + if self._status_event is None: + self._status_event = asyncio.Event() + + start = time.time() + in_progress_states = {"working"} + poll_interval = 0.5 # Fallback polling interval (500ms) + + while True: + # Check cached status first (updated by notifications) + if self._status_cache: + current = self._status_cache.status + if state is None: + if current not in in_progress_states: + return self._status_cache + elif current == state: + return self._status_cache + + # Check timeout + elapsed = time.time() - start + if elapsed >= timeout: + raise TimeoutError( + f"Task {self._task_id} did not reach {state or 'terminal state'} within {timeout}s" + ) + + remaining = timeout - elapsed + + # Wait for notification event OR poll timeout + try: + await asyncio.wait_for( + self._status_event.wait(), timeout=min(poll_interval, remaining) + ) + self._status_event.clear() + except asyncio.TimeoutError: + # Fallback: poll server (notification didn't arrive in time) + self._status_cache = await self._client.get_task_status(self._task_id) + + async def _wait_terminal(self, timeout: float = 300.0) -> GetTaskResult: + """Wait until task reaches a terminal state (completed, failed, cancelled). + + Unlike wait(), this will not return on input_required — it continues + waiting until the task fully resolves. Used internally by result(). + """ + terminal_states = {"completed", "failed", "cancelled"} + status = await self.wait(timeout=timeout) + while status.status not in terminal_states: + # Task is in a non-terminal state (e.g. input_required) — reset + # cache so the next wait() call blocks instead of returning immediately. + self._status_cache = None + status = await self.wait(timeout=timeout) + return status + + async def cancel(self) -> None: + """Cancel this task, transitioning it to cancelled state. + + Sends a tasks/cancel protocol request. The server will attempt to halt + execution and move the task to cancelled state. + + Note: If server executed immediately (graceful degradation), this is a no-op + as there's no server-side task to cancel. + """ + if self._is_immediate: + # No server-side task to cancel + return + self._check_client_connected() + await self._client.cancel_task(self._task_id) + # Invalidate cache to force fresh status fetch + self._status_cache = None + + def __await__(self): + """Allow 'await task' to get result.""" + return self.result().__await__() + + +class ToolTask(Task["CallToolResult"]): + """ + Represents a tool call that may execute in background or immediately. + + Provides a uniform API whether the server accepts background execution + or executes synchronously (graceful degradation per SEP-1686). + + Usage: + task = await client.call_tool_as_task("analyze", args) + + # Check status + status = await task.status() + + # Wait for completion + await task.wait() + + # Get result (waits if needed) + result = await task.result() # Returns CallToolResult + + # Or just await the task directly + result = await task + """ + + def __init__( + self, + client: Client, + task_id: str, + tool_name: str, + immediate_result: CallToolResult | None = None, + raise_on_error: bool = True, + ): + """ + Create a ToolTask wrapper. + + Args: + client: The FastMCP client + task_id: The task identifier + tool_name: Name of the tool being executed + immediate_result: If server executed synchronously, the immediate result + raise_on_error: Whether task.result() should raise ToolError on errors + """ + super().__init__(client, task_id, immediate_result) + self._tool_name = tool_name + self._raise_on_error = raise_on_error + + async def result(self) -> CallToolResult: + """Wait for and return the tool result. + + If server executed immediately, returns the immediate result. + Otherwise waits for background task to complete and retrieves result. + + Returns: + CallToolResult: The parsed tool result (same as call_tool returns) + """ + # Check cache first + if self._cached_result is not None: + return self._cached_result + + if self._is_immediate: + assert self._immediate_result is not None # Type narrowing + result = self._immediate_result + if result.is_error and self._raise_on_error: + if result.content and isinstance( + result.content[0], mcp_types.TextContent + ): + msg = result.content[0].text + else: + msg = f"Tool '{self._tool_name}' returned an error" + raise ToolError(msg) + else: + # Check client connected + self._check_client_connected() + + # Wait for completion using event-based wait (respects notifications) + await self._wait_terminal() + + # Get the raw result (dict or CallToolResult) + raw_result = await self._client.get_task_result(self._task_id) + + # Convert to CallToolResult if needed and parse + if isinstance(raw_result, dict): + # Raw dict from get_task_result - parse as CallToolResult + mcp_result = mcp_types.CallToolResult.model_validate(raw_result) + result = await self._client._parse_call_tool_result( + self._tool_name, + mcp_result, + raise_on_error=self._raise_on_error, + ) + elif isinstance(raw_result, mcp_types.CallToolResult): + # Already a CallToolResult from MCP protocol - parse it + result = await self._client._parse_call_tool_result( + self._tool_name, + raw_result, + raise_on_error=self._raise_on_error, + ) + else: + # Legacy ToolResult format - convert to MCP type + if hasattr(raw_result, "content") and hasattr( + raw_result, "structured_content" + ): + mcp_result = mcp_types.CallToolResult( + content=raw_result.content, + structured_content=raw_result.structured_content, + _meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + ) + result = await self._client._parse_call_tool_result( + self._tool_name, + mcp_result, + raise_on_error=self._raise_on_error, + ) + else: + # Unknown type - just return it + result = raw_result + + # Cache before returning + self._cached_result = result + return result + + +class PromptTask(Task[mcp_types.GetPromptResult]): + """ + Represents a prompt call that may execute in background or immediately. + + Provides a uniform API whether the server accepts background execution + or executes synchronously (graceful degradation per SEP-1686). + + Usage: + task = await client.get_prompt_as_task("analyze", args) + result = await task # Returns GetPromptResult + """ + + def __init__( + self, + client: Client, + task_id: str, + prompt_name: str, + immediate_result: mcp_types.GetPromptResult | None = None, + ): + """ + Create a PromptTask wrapper. + + Args: + client: The FastMCP client + task_id: The task identifier + prompt_name: Name of the prompt being executed + immediate_result: If server executed synchronously, the immediate result + """ + super().__init__(client, task_id, immediate_result) + self._prompt_name = prompt_name + + async def result(self) -> mcp_types.GetPromptResult: + """Wait for and return the prompt result. + + If server executed immediately, returns the immediate result. + Otherwise waits for background task to complete and retrieves result. + + Returns: + GetPromptResult: The prompt result with messages and description + """ + # Check cache first + if self._cached_result is not None: + return self._cached_result + + if self._is_immediate: + assert self._immediate_result is not None + result = self._immediate_result + else: + # Check client connected + self._check_client_connected() + + # Wait for completion using event-based wait (respects notifications) + await self._wait_terminal() + + # Get the raw MCP result + mcp_result = await self._client.get_task_result(self._task_id) + + # Parse as GetPromptResult + result = mcp_types.GetPromptResult.model_validate(mcp_result) + + # Cache before returning + self._cached_result = result + return result + + +class ResourceTask( + Task[list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]] +): + """ + Represents a resource read that may execute in background or immediately. + + Provides a uniform API whether the server accepts background execution + or executes synchronously (graceful degradation per SEP-1686). + + Usage: + task = await client.read_resource_as_task("file://data.txt") + contents = await task # Returns list[ReadResourceContents] + """ + + def __init__( + self, + client: Client, + task_id: str, + uri: str, + immediate_result: list[ + mcp_types.TextResourceContents | mcp_types.BlobResourceContents + ] + | None = None, + ): + """ + Create a ResourceTask wrapper. + + Args: + client: The FastMCP client + task_id: The task identifier + uri: URI of the resource being read + immediate_result: If server executed synchronously, the immediate result + """ + super().__init__(client, task_id, immediate_result) + self._uri = uri + + async def result( + self, + ) -> list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents]: + """Wait for and return the resource contents. + + If server executed immediately, returns the immediate result. + Otherwise waits for background task to complete and retrieves result. + + Returns: + list[ReadResourceContents]: The resource contents + """ + # Check cache first + if self._cached_result is not None: + return self._cached_result + + if self._is_immediate: + assert self._immediate_result is not None + result = self._immediate_result + else: + # Check client connected + self._check_client_connected() + + # Wait for completion using event-based wait (respects notifications) + await self._wait_terminal() + + # Get the raw MCP result + mcp_result = await self._client.get_task_result(self._task_id) + + # Parse as ReadResourceResult or extract contents + if isinstance(mcp_result, mcp_types.ReadResourceResult): + # Already parsed by TasksResponse - extract contents + result = list(mcp_result.contents) + elif isinstance(mcp_result, dict) and "contents" in mcp_result: + # Dict format - parse each content item + parsed_contents = [] + for item in mcp_result["contents"]: + if isinstance(item, dict): + if "blob" in item: + parsed_contents.append( + mcp_types.BlobResourceContents.model_validate(item) + ) + else: + parsed_contents.append( + mcp_types.TextResourceContents.model_validate(item) + ) + else: + parsed_contents.append(item) + result = parsed_contents + else: + # Fallback - might be the list directly + result = mcp_result if isinstance(mcp_result, list) else [mcp_result] + + # Cache before returning + self._cached_result = result + return result diff --git a/fastmcp_slim/fastmcp/client/telemetry.py b/fastmcp_slim/fastmcp/client/telemetry.py index 785346ba9..b6fb3e464 100644 --- a/fastmcp_slim/fastmcp/client/telemetry.py +++ b/fastmcp_slim/fastmcp/client/telemetry.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from opentelemetry.trace import Span, SpanKind, Status, StatusCode from fastmcp.exceptions import ToolError as _ToolError -from fastmcp.telemetry import get_tracer, restore_dropped_attributes +from fastmcp.telemetry import get_tracer @contextmanager @@ -42,18 +42,16 @@ def client_span( with tracer.start_as_current_span( name, kind=SpanKind.CLIENT, attributes=attrs ) as span: - # Restore: `attributes=attrs` above lets on_start hooks and the + # Reapply: `attributes=attrs` above lets on_start hooks and the # sampler see these values at creation time. But OTel's # Tracer.start_span builds the span from # `sampling_result.attributes`, not the `attributes` kwarg directly — # a custom Sampler whose SamplingResult.attributes defaults to None - # silently drops everything we passed. This only fires when the span - # ends up with no attributes at all, so any sampler that supplied - # attributes of its own — forwarding ours, redacting or replacing - # some, or substituting entirely its own — is left untouched, as is - # an SDK attribute limit that evicted some. + # silently drops everything we passed. Reapplying here (additive, + # can't clobber anything a sampler legitimately added) guarantees + # FastMCP's attributes survive regardless of sampler behavior. if span.is_recording(): - restore_dropped_attributes(span, attrs) + span.set_attributes(attrs) try: yield span except Exception as e: diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py index 422f3ed9c..36683f75d 100644 --- a/fastmcp_slim/fastmcp/client/transports/base.py +++ b/fastmcp_slim/fastmcp/client/transports/base.py @@ -1,13 +1,12 @@ import abc import contextlib -from collections.abc import AsyncIterator, Mapping, Sequence -from dataclasses import dataclass +from collections.abc import AsyncIterator, Sequence from typing import Any, Literal, TypeVar import httpx2 import mcp_types from mcp import ClientSession -from mcp.client.extension import NotificationBinding, ResultClaim +from mcp.client.extension import NotificationBinding from mcp.client.session import ( ElicitationFnT, ListRootsFnT, @@ -21,7 +20,7 @@ from typing_extensions import TypedDict, Unpack ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport") -class ClientSessionKwargs(TypedDict, total=False): +class SessionKwargs(TypedDict, total=False): """Keyword arguments for the MCP ClientSession constructor.""" read_timeout_seconds: float | None @@ -29,47 +28,10 @@ class ClientSessionKwargs(TypedDict, total=False): sampling_capabilities: mcp_types.SamplingCapability | None list_roots_callback: ListRootsFnT | None logging_callback: LoggingFnT | None - log_level: mcp_types.LoggingLevel | None elicitation_callback: ElicitationFnT | None message_handler: MessageHandlerFnT | None client_info: mcp_types.Implementation | None notification_bindings: Sequence[NotificationBinding[Any]] | None - extensions: dict[str, dict[str, Any]] | None - result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None - - -@dataclass(frozen=True) -class TransportOptions: - """How one client wants its connection built. - - These belong to the client rather than to the transport, so a transport - shared between clients doesn't leak one client's settings to another. - - Attributes: - session_class: The ClientSession class to instantiate. Proxies supply a - session that skips output-schema validation, since they relay - results rather than consume them. - forward_incoming_headers: Whether to forward the inbound request's - authorization header upstream. Only appropriate for proxies, where - the caller's credentials are meant to be propagated. Honored by the - HTTP and SSE transports; ignored by the others. - backend_mode: The connect `mode` to give backend clients that a wrapping - transport builds on this client's behalf, so a chain of connections - speaks one protocol era end to end. `None` leaves each backend - client at its own default. Honored by `MCPConfigTransport`, whose - multi-server form mounts a proxy per configured server; ignored by - transports that connect to a single backend directly, since those - carry the connecting client's own session and era. - """ - - session_class: type[ClientSession] = ClientSession - forward_incoming_headers: bool = False - backend_mode: str | None = None - - -# SessionKwargs stays exactly the ClientSession constructor's parameters, so a -# transport can splat it into ClientSession without filtering. -SessionKwargs = ClientSessionKwargs class ClientTransport(abc.ABC): @@ -81,20 +43,10 @@ class ClientTransport(abc.ABC): """ - #: Whether this transport can only carry the legacy (handshake) protocol era. - #: The modern `2026-07-28` era is sessionless and served over Streamable HTTP; - #: the SSE transport predates it and cannot serve it. When True, a client with - #: `mode="auto"` negotiates the legacy handshake directly rather than probing - #: `server/discover` (which some servers answer over SSE but then cannot serve). - legacy_only: bool = False - @abc.abstractmethod @contextlib.asynccontextmanager async def connect_session( - self, - *, - transport_options: TransportOptions | None = None, - **session_kwargs: Unpack[SessionKwargs], + self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: """ Establishes a connection and yields an active ClientSession. @@ -106,9 +58,6 @@ class ClientTransport(abc.ABC): within this context. Args: - transport_options: How the connecting client wants this connection - built. Defaults apply when omitted. A transport - that wraps others must pass this along. **session_kwargs: Keyword arguments to pass to the ClientSession constructor (e.g., callbacks, timeouts). diff --git a/fastmcp_slim/fastmcp/client/transports/config.py b/fastmcp_slim/fastmcp/client/transports/config.py index 3e391b44f..472e94b86 100644 --- a/fastmcp_slim/fastmcp/client/transports/config.py +++ b/fastmcp_slim/fastmcp/client/transports/config.py @@ -6,11 +6,7 @@ from mcp import ClientSession from typing_extensions import Unpack from fastmcp import _install_hints -from fastmcp.client.transports.base import ( - ClientTransport, - SessionKwargs, - TransportOptions, -) +from fastmcp.client.transports.base import ClientTransport, SessionKwargs from fastmcp.client.transports.memory import FastMCPTransport from fastmcp.mcp_config import ( MCPConfig, @@ -24,8 +20,6 @@ from fastmcp.mcp_config import ( from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: - from mcp.server.request_state import RequestStateSecurity - from fastmcp.server.server import FastMCP logger = get_logger(__name__) @@ -84,7 +78,6 @@ class MCPConfigTransport(ClientTransport): self.config = config self.name_as_prefix = name_as_prefix self._transports: list[ClientTransport] = [] - self._request_state_security: RequestStateSecurity | None = None if not self.config.mcpServers: raise ValueError("No MCP servers defined in the config") @@ -93,50 +86,14 @@ class MCPConfigTransport(ClientTransport): if len(self.config.mcpServers) == 1: self.transport = next(iter(self.config.mcpServers.values())).to_transport() self._transports.append(self.transport) - else: - # Sealing policy for the composite router built in `connect_session`. - # It is held here, not on the router, because the router is rebuilt - # on every connection while a guard tool's multi-round-trip spans - # several of them (a proxy builds a fresh backend client per - # request). A per-router key would seal `request_state` on one round - # and reject its own token on the next. Only multi-server configs - # mount a router, so single-server configs skip the import entirely - # (it pulls in the SDK's server tier). Aliased so the local binding - # does not shadow the type-checking-only name in the annotation - # above. - from mcp.server.request_state import ( - RequestStateSecurity as _RequestStateSecurity, - ) - - self._request_state_security = _RequestStateSecurity.ephemeral() - - @property - def legacy_only(self) -> bool: - """Whether this config can only carry the legacy protocol era. - - A single-server config delegates directly to the underlying transport - (no proxy), so it inherits that transport's era capability — a modern - Streamable HTTP backend must stay modern-capable under `mode="auto"`. - A multi-server config mounts each backend behind a legacy-era - `ProxyClient` on a composite server, so the composite it exposes is - legacy-era and `mode="auto"` should negotiate the handshake. - """ - if len(self.config.mcpServers) == 1: - return self.transport.legacy_only - return True @contextlib.asynccontextmanager async def connect_session( - self, - *, - transport_options: TransportOptions | None = None, - **session_kwargs: Unpack[SessionKwargs], + self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: # Single server - delegate directly to pre-created transport if len(self.config.mcpServers) == 1: - async with self.transport.connect_session( - transport_options=transport_options, **session_kwargs - ) as session: + async with self.transport.connect_session(**session_kwargs) as session: yield session return @@ -152,18 +109,7 @@ class MCPConfigTransport(ClientTransport): ) from exc timeout = session_kwargs.get("read_timeout_seconds") - composite = FastMCP[Any]( - name="MCPRouter", request_state_security=self._request_state_security - ) - - # The composite is only a router: every real backend is reached through - # one of the mounted proxies below, so the era the connecting client - # negotiates with the composite means nothing unless those backend legs - # negotiate it too. `backend_mode` carries the connecting client's era - # down to them, keeping the whole chain on one era end to end. - backend_mode = ( - transport_options.backend_mode if transport_options is not None else None - ) + composite = FastMCP[Any](name="MCPRouter") async with contextlib.AsyncExitStack() as stack: # Close any previous transports from prior connections to avoid leaking @@ -174,7 +120,7 @@ class MCPConfigTransport(ClientTransport): for name, server_config in self.config.mcpServers.items(): try: transport, _client, proxy = await self._create_proxy( - name, server_config, timeout, stack, backend_mode + name, server_config, timeout, stack ) except Exception: # Broad catch is intentional: failure modes # are diverse (OSError, TimeoutError, RuntimeError, etc.) @@ -192,7 +138,7 @@ class MCPConfigTransport(ClientTransport): raise ConnectionError("All MCP servers failed to connect") async with FastMCPTransport(mcp=composite).connect_session( - transport_options=transport_options, **session_kwargs + **session_kwargs ) as session: yield session @@ -202,7 +148,6 @@ class MCPConfigTransport(ClientTransport): config: MCPServerTypes, timeout: float | None, stack: contextlib.AsyncExitStack, - backend_mode: str | None = None, ) -> tuple[ClientTransport, Any, "FastMCP[Any]"]: """Create underlying transport, proxy client, and proxy server for a single backend. @@ -210,9 +155,6 @@ class MCPConfigTransport(ClientTransport): passed to create_proxy so the factory sees it as connected and reuses the same session for all tool calls (instead of creating fresh copies). - `backend_mode` is the connect mode the calling client wants this backend - leg to negotiate; `None` leaves the client at its own default era. - Returns a tuple of (transport, proxy_client, proxy_server). """ # Import here to avoid circular dependency @@ -237,12 +179,7 @@ class MCPConfigTransport(ClientTransport): else: transport = config.to_transport() - client_kwargs: dict[str, Any] = {} - if backend_mode is not None: - client_kwargs["mode"] = backend_mode - client = StatefulProxyClient( - transport=transport, timeout=timeout, **client_kwargs - ) + client = StatefulProxyClient(transport=transport, timeout=timeout) # Connect the client *before* create_proxy so _create_client_factory # detects it as connected and reuses it for all tool calls, preserving # the session ID across requests. StatefulProxyClient is used instead diff --git a/fastmcp_slim/fastmcp/client/transports/http.py b/fastmcp_slim/fastmcp/client/transports/http.py index 3ba827931..a57379d80 100644 --- a/fastmcp_slim/fastmcp/client/transports/http.py +++ b/fastmcp_slim/fastmcp/client/transports/http.py @@ -15,17 +15,9 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.client.auth.bearer import BearerAuth -from fastmcp.client.auth.client_credentials import ( - ClientCredentialsOAuthProvider, - PrivateKeyJWTOAuthProvider, -) from fastmcp.client.auth.oauth import OAuth from fastmcp.client.dependencies import get_http_headers -from fastmcp.client.transports.base import ( - ClientTransport, - SessionKwargs, - TransportOptions, -) +from fastmcp.client.transports.base import ClientTransport, SessionKwargs class StreamableHttpTransport(ClientTransport): @@ -82,6 +74,8 @@ class StreamableHttpTransport(ClientTransport): self._set_auth(auth) + self.forward_incoming_headers: bool = False + # SDK v2's streamable_http_client no longer exposes a get_session_id # callback. We recover the session id ourselves by capturing the # `mcp-session-id` response header via an httpx event hook on the @@ -116,11 +110,6 @@ class StreamableHttpTransport(ClientTransport): if factory is not None: auth.httpx_client_factory = factory resolved = auth - elif isinstance( - auth, (ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider) - ): - auth._bind(self.url) - resolved = auth elif isinstance(auth, str): resolved = BearerAuth(auth) else: @@ -154,18 +143,13 @@ class StreamableHttpTransport(ClientTransport): @contextlib.asynccontextmanager async def connect_session( - self, - *, - transport_options: TransportOptions | None = None, - **session_kwargs: Unpack[SessionKwargs], + self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - options = transport_options or TransportOptions() - # When used in a proxy, forward the inbound request's authorization # header to the upstream server. This is off by default so that a # plain Client used inside a server tool handler doesn't accidentally # leak the caller's credentials to an unrelated remote server. - if options.forward_incoming_headers: + if self.forward_incoming_headers: headers = get_http_headers(include={"authorization"}) | self.headers else: headers = dict(self.headers) @@ -218,9 +202,7 @@ class StreamableHttpTransport(ClientTransport): read_stream, write_stream, ), - options.session_class( - read_stream, write_stream, **session_kwargs - ) as session, + ClientSession(read_stream, write_stream, **session_kwargs) as session, ): yield session diff --git a/fastmcp_slim/fastmcp/client/transports/memory.py b/fastmcp_slim/fastmcp/client/transports/memory.py index 9969c5018..ad683ef32 100644 --- a/fastmcp_slim/fastmcp/client/transports/memory.py +++ b/fastmcp_slim/fastmcp/client/transports/memory.py @@ -11,11 +11,7 @@ from mcp.shared.memory import create_client_server_memory_streams from typing_extensions import Unpack from fastmcp import _install_hints -from fastmcp.client.transports.base import ( - ClientTransport, - SessionKwargs, - TransportOptions, -) +from fastmcp.client.transports.base import ClientTransport, SessionKwargs if TYPE_CHECKING: from fastmcp.server.server import FastMCP @@ -56,12 +52,8 @@ class FastMCPTransport(ClientTransport): @contextlib.asynccontextmanager async def connect_session( - self, - *, - transport_options: TransportOptions | None = None, - **session_kwargs: Unpack[SessionKwargs], + self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - options = transport_options or TransportOptions() async with create_client_server_memory_streams() as ( client_streams, server_streams, @@ -96,7 +88,7 @@ class FastMCPTransport(ClientTransport): ) try: - async with options.session_class( + async with ClientSession( read_stream=client_read, write_stream=client_write, **session_kwargs, diff --git a/fastmcp_slim/fastmcp/client/transports/sse.py b/fastmcp_slim/fastmcp/client/transports/sse.py index ed5602444..25204091c 100644 --- a/fastmcp_slim/fastmcp/client/transports/sse.py +++ b/fastmcp_slim/fastmcp/client/transports/sse.py @@ -16,27 +16,15 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.client.auth.bearer import BearerAuth -from fastmcp.client.auth.client_credentials import ( - ClientCredentialsOAuthProvider, - PrivateKeyJWTOAuthProvider, -) from fastmcp.client.auth.oauth import OAuth from fastmcp.client.dependencies import get_http_headers -from fastmcp.client.transports.base import ( - ClientTransport, - SessionKwargs, - TransportOptions, -) +from fastmcp.client.transports.base import ClientTransport, SessionKwargs from fastmcp.utilities.timeout import normalize_timeout_to_timedelta class SSETransport(ClientTransport): """Transport implementation that connects to an MCP server via Server-Sent Events.""" - # SSE predates the sessionless modern era and cannot serve it; a client with - # `mode="auto"` negotiates the legacy handshake directly over SSE. - legacy_only = True - def __init__( self, url: str | AnyUrl, @@ -73,6 +61,8 @@ class SSETransport(ClientTransport): self._set_auth(auth) + self.forward_incoming_headers: bool = False + self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) def _set_auth(self, auth: httpx2.Auth | Literal["oauth"] | str | None): @@ -92,11 +82,6 @@ class SSETransport(ClientTransport): if factory is not None: auth.httpx_client_factory = factory resolved = auth - elif isinstance( - auth, (ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider) - ): - auth._bind(self.url) - resolved = auth elif isinstance(auth, str): resolved = BearerAuth(auth) else: @@ -130,19 +115,15 @@ class SSETransport(ClientTransport): @contextlib.asynccontextmanager async def connect_session( - self, - *, - transport_options: TransportOptions | None = None, - **session_kwargs: Unpack[SessionKwargs], + self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - options = transport_options or TransportOptions() client_kwargs: dict[str, Any] = {} # When used in a proxy, forward the inbound request's authorization # header to the upstream server. This is off by default so that a # plain Client used inside a server tool handler doesn't accidentally # leak the caller's credentials to an unrelated remote server. - if options.forward_incoming_headers: + if self.forward_incoming_headers: client_kwargs["headers"] = ( get_http_headers(include={"authorization"}) | self.headers ) @@ -168,7 +149,7 @@ class SSETransport(ClientTransport): async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport: read_stream, write_stream = transport - async with options.session_class( + async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: yield session diff --git a/fastmcp_slim/fastmcp/client/transports/stdio.py b/fastmcp_slim/fastmcp/client/transports/stdio.py index 8967934b7..a0067bfa0 100644 --- a/fastmcp_slim/fastmcp/client/transports/stdio.py +++ b/fastmcp_slim/fastmcp/client/transports/stdio.py @@ -12,11 +12,7 @@ from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from typing_extensions import Unpack -from fastmcp.client.transports.base import ( - ClientTransport, - SessionKwargs, - TransportOptions, -) +from fastmcp.client.transports.base import ClientTransport, SessionKwargs from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -67,27 +63,17 @@ class StdioTransport(ClientTransport): self.log_file = log_file self._session: ClientSession | None = None - self._session_options: TransportOptions | None = None - self._active_sessions = 0 - self._connect_lock = anyio.Lock() self._connect_task: asyncio.Task | None = None self._ready_event = anyio.Event() self._stop_event = anyio.Event() @contextlib.asynccontextmanager async def connect_session( - self, - *, - transport_options: TransportOptions | None = None, - **session_kwargs: Unpack[SessionKwargs], + self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: try: - await self.connect(transport_options=transport_options, **session_kwargs) - self._active_sessions += 1 - try: - yield cast(ClientSession, self._session) - finally: - self._active_sessions -= 1 + await self.connect(**session_kwargs) + yield cast(ClientSession, self._session) finally: if not self.keep_alive: await self.disconnect() @@ -95,76 +81,47 @@ class StdioTransport(ClientTransport): logger.debug("Stdio transport has keep_alive=True, not disconnecting") async def connect( - self, - *, - transport_options: TransportOptions | None = None, - **session_kwargs: Unpack[SessionKwargs], + self, **session_kwargs: Unpack[SessionKwargs] ) -> ClientSession | None: - options = transport_options or TransportOptions() + # If the connect task completed or the session's streams are dead, + # the subprocess has exited. Tear down so we can start fresh. + if self._connect_task is not None and ( + self._connect_task.done() or self._is_session_dead() + ): + await self.disconnect() - # Serialized so concurrent callers can't each decide to replace the - # session and race to spawn competing subprocesses. - async with self._connect_lock: - # A kept-alive session was built for one client's options; handing it - # to a client that wants different ones would silently give it the - # first client's behavior. Rebuild it when it's idle; refuse when - # another client is using it, since tearing it down would break them. - if self._connect_task is not None and self._session_options != options: - if self._active_sessions: - raise RuntimeError( - "This stdio transport has a live session built for different " - "connection options and another client is still using it. " - "Sharing one transport across clients that need different " - "sessions is not supported; give each client its own transport." - ) - await self.disconnect() + if self._connect_task is not None: + return - # If the connect task completed or the session's streams are dead, - # the subprocess has exited. Tear down so we can start fresh. - if self._connect_task is not None and ( - self._connect_task.done() or self._is_session_dead() - ): - await self.disconnect() + session_future: asyncio.Future[ClientSession] = asyncio.Future() - if self._connect_task is not None: - return - - session_future: asyncio.Future[ClientSession] = asyncio.Future() - - # Recorded before the connect completes: while it is in flight the - # session already belongs to these options, and a concurrent caller - # comparing against an unset value would read it as a mismatch and - # tear down the connection being established. - self._session_options = options - - # start the connection task - self._connect_task = asyncio.create_task( - _stdio_transport_connect_task( - command=self.command, - args=self.args, - env=self.env, - cwd=self.cwd, - log_file=self.log_file, - # TODO(ty): remove when ty supports Unpack[TypedDict] inference - session_kwargs=session_kwargs, # type: ignore[arg-type] - transport_options=options, - ready_event=self._ready_event, - stop_event=self._stop_event, - session_future=session_future, - ) + # start the connection task + self._connect_task = asyncio.create_task( + _stdio_transport_connect_task( + command=self.command, + args=self.args, + env=self.env, + cwd=self.cwd, + log_file=self.log_file, + # TODO(ty): remove when ty supports Unpack[TypedDict] inference + session_kwargs=session_kwargs, # type: ignore[arg-type] + ready_event=self._ready_event, + stop_event=self._stop_event, + session_future=session_future, ) + ) - # wait for the client to be ready before returning - await self._ready_event.wait() + # wait for the client to be ready before returning + await self._ready_event.wait() - # Check if connect task completed with an exception (early failure) - if self._connect_task.done(): - exception = self._connect_task.exception() - if exception is not None: - raise exception + # Check if connect task completed with an exception (early failure) + if self._connect_task.done(): + exception = self._connect_task.exception() + if exception is not None: + raise exception - self._session = await session_future - return self._session + self._session = await session_future + return self._session async def disconnect(self): if self._connect_task is None: @@ -173,25 +130,13 @@ class StdioTransport(ClientTransport): # signal the connection task to stop self._stop_event.set() - # Wait without propagating the connection task's cancellation into - # this caller. Cancellation of this wait therefore still belongs to - # the caller and must propagate normally. - connect_task = self._connect_task - await asyncio.wait({connect_task}) - try: - _ = connect_task.result() - except asyncio.CancelledError: - pass - except Exception: - logger.debug( - "Suppressed exception from stdio connection task during disconnect", - exc_info=True, - ) + # wait for the connection task to finish cleanly + with contextlib.suppress(Exception): + await self._connect_task # reset variables and events for potential future reconnects self._connect_task = None self._session = None - self._session_options = None self._stop_event = anyio.Event() self._ready_event = anyio.Event() @@ -236,7 +181,6 @@ async def _stdio_transport_connect_task( cwd: str | None, log_file: Path | TextIO | None, session_kwargs: SessionKwargs, - transport_options: TransportOptions, ready_event: anyio.Event, stop_event: anyio.Event, session_future: asyncio.Future[ClientSession], @@ -268,9 +212,7 @@ async def _stdio_transport_connect_task( read_stream, write_stream = transport session_future.set_result( await stack.enter_async_context( - transport_options.session_class( - read_stream, write_stream, **session_kwargs - ) + ClientSession(read_stream, write_stream, **session_kwargs) ) ) diff --git a/fastmcp_slim/fastmcp/decorators.py b/fastmcp_slim/fastmcp/decorators.py index b61a90c62..75dff25ac 100644 --- a/fastmcp_slim/fastmcp/decorators.py +++ b/fastmcp_slim/fastmcp/decorators.py @@ -8,8 +8,8 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable if TYPE_CHECKING: from fastmcp.prompts.function_prompt import PromptMeta from fastmcp.resources.function_resource import ResourceMeta + from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.function_tool import ToolMeta - from fastmcp.utilities.tasks import TaskConfig FastMCPMeta = ToolMeta | ResourceMeta | PromptMeta diff --git a/fastmcp_slim/fastmcp/dependencies.py b/fastmcp_slim/fastmcp/dependencies.py index 138486f88..2aa8c145a 100644 --- a/fastmcp_slim/fastmcp/dependencies.py +++ b/fastmcp_slim/fastmcp/dependencies.py @@ -4,21 +4,20 @@ This module re-exports dependency injection symbols to provide a clean, centralized import location for all dependency-related functionality. DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket -using the uncalled-for DI engine. The docket-specific dependencies -(``CurrentDocket``, ``CurrentWorker``) live in the ``fastmcp-tasks`` package -(``fastmcp_tasks.dependencies``). +using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, +CurrentWorker) and background task execution require fastmcp[tasks]. """ -from typing import Any - from uncalled_for import Dependency, Depends, Shared from fastmcp.server.dependencies import ( CurrentAccessToken, CurrentContext, + CurrentDocket, CurrentFastMCP, CurrentHeaders, CurrentRequest, + CurrentWorker, Progress, ProgressLike, TokenClaim, @@ -27,9 +26,11 @@ from fastmcp.server.dependencies import ( __all__ = [ "CurrentAccessToken", "CurrentContext", + "CurrentDocket", "CurrentFastMCP", "CurrentHeaders", "CurrentRequest", + "CurrentWorker", "Dependency", "Depends", "Progress", @@ -37,17 +38,3 @@ __all__ = [ "Shared", "TokenClaim", ] - -# Docket-specific dependencies moved to the fastmcp-tasks package. Point users -# there instead of raising a bare AttributeError. -_MOVED_TO_TASKS = {"CurrentDocket", "CurrentWorker"} - - -def __getattr__(name: str) -> Any: - if name in _MOVED_TO_TASKS: - raise ImportError( - f"{name!r} moved to the fastmcp-tasks package. Install it with " - f"`pip install 'fastmcp[tasks]'` and import from " - f"`fastmcp_tasks.dependencies`." - ) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/fastmcp_slim/fastmcp/exceptions.py b/fastmcp_slim/fastmcp/exceptions.py index 3fa255acd..b74b50998 100644 --- a/fastmcp_slim/fastmcp/exceptions.py +++ b/fastmcp_slim/fastmcp/exceptions.py @@ -5,8 +5,6 @@ from typing import Any from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData -from fastmcp import _warnings - try: from mcp import MCPError except ImportError: @@ -32,7 +30,14 @@ except ImportError: # see the migration notes. McpError = MCPError -FastMCPDeprecationWarning = _warnings.FastMCPDeprecationWarning + +class FastMCPDeprecationWarning(DeprecationWarning): + """Deprecation warning for FastMCP APIs. + + Subclass of DeprecationWarning so that standard warning filters + still apply, but FastMCP can selectively enable its own warnings + without affecting other libraries in the process. + """ class FastMCPError(Exception): @@ -90,30 +95,6 @@ class AuthorizationError(FastMCPError): """Error when authorization check fails.""" -class InsufficientScopeError(AuthorizationError): - """Authorization failed because the token is missing required OAuth scopes. - - Unlike a bare ``AuthorizationError``, this carries the specific scopes the - caller must obtain. A component-level scope shortfall can then be signalled - as a spec-correct ``insufficient_scope`` step-up (SEP-2350 / RFC 6750 §3), - naming exactly what to re-authorize for instead of an opaque denial. The - named scopes are only the *unmet* ones, so an existing grant is accumulated - rather than replaced when the caller re-authorizes. - """ - - def __init__( - self, - required_scopes: list[str], - *, - message: str | None = None, - ) -> None: - self.required_scopes = list(required_scopes) - if message is None: - named = ", ".join(self.required_scopes) or "(unknown)" - message = f"Insufficient scope. Required: {named}" - super().__init__(message) - - def to_mcp_error(exc: Exception, *, default_code: int = INTERNAL_ERROR) -> MCPError: """Translate a FastMCP exception into a wire-format ``MCPError``. diff --git a/tests/tasks/__init__.py b/fastmcp_slim/fastmcp/experimental/sampling/__init__.py similarity index 100% rename from tests/tasks/__init__.py rename to fastmcp_slim/fastmcp/experimental/sampling/__init__.py diff --git a/fastmcp_slim/fastmcp/experimental/sampling/handlers/__init__.py b/fastmcp_slim/fastmcp/experimental/sampling/handlers/__init__.py new file mode 100644 index 000000000..627dfd011 --- /dev/null +++ b/fastmcp_slim/fastmcp/experimental/sampling/handlers/__init__.py @@ -0,0 +1,5 @@ +# Re-export for backwards compatibility +# The canonical location is now fastmcp.client.sampling.handlers +from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler + +__all__ = ["OpenAISamplingHandler"] diff --git a/fastmcp_slim/fastmcp/experimental/sampling/handlers/openai.py b/fastmcp_slim/fastmcp/experimental/sampling/handlers/openai.py new file mode 100644 index 000000000..b466f7a77 --- /dev/null +++ b/fastmcp_slim/fastmcp/experimental/sampling/handlers/openai.py @@ -0,0 +1,5 @@ +# Re-export for backwards compatibility +# The canonical location is now fastmcp.client.sampling.handlers.openai +from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler + +__all__ = ["OpenAISamplingHandler"] diff --git a/fastmcp_slim/fastmcp/mcp_config.py b/fastmcp_slim/fastmcp/mcp_config.py index b18ea1a3e..01416bd2d 100644 --- a/fastmcp_slim/fastmcp/mcp_config.py +++ b/fastmcp_slim/fastmcp/mcp_config.py @@ -45,7 +45,6 @@ from fastmcp import _install_hints if TYPE_CHECKING: from fastmcp.client.transports import ( ClientTransport, - FastMCPTransport, SSETransport, StdioTransport, StreamableHttpTransport, @@ -138,9 +137,7 @@ class _TransformingMCPServerMixin(BaseModel): ) from exc transport = cast("ClientTransport", super().to_transport()) # ty: ignore[unresolved-attribute] - # The proxy that wraps this client forwards the initialize handshake and - # server-initiated features, which require the legacy era. - client = Client(transport=transport, name=client_name, mode="legacy") + client = Client(transport=transport, name=client_name) wrapped_mcp_server = create_proxy(client, name=server_name) if self.include_tags is not None: @@ -154,7 +151,7 @@ class _TransformingMCPServerMixin(BaseModel): return wrapped_mcp_server, transport - def to_transport(self) -> FastMCPTransport: + def to_transport(self) -> ClientTransport: """Get the transport for the transforming MCP server.""" try: from fastmcp.client.transports import FastMCPTransport @@ -165,16 +162,7 @@ class _TransformingMCPServerMixin(BaseModel): ) ) from exc - transport = FastMCPTransport(mcp=self._to_server_and_underlying_transport()[0]) - # The wrapped proxy talks to its upstream over the legacy era (it pins - # the backend client to `mode="legacy"` to forward the initialize - # handshake and server-initiated features). Mark the wrapper legacy-only - # so a default `Client(config)` on `mode="auto"` negotiates legacy with - # it too, keeping both legs on the same era — otherwise a modern - # frontend would receive a forwarded server-initiated request that the - # modern era has no back-channel for. - transport.legacy_only = True - return transport + return FastMCPTransport(mcp=self._to_server_and_underlying_transport()[0]) class StdioMCPServer(BaseModel): @@ -210,7 +198,7 @@ class StdioMCPServer(BaseModel): model_config = ConfigDict(extra="allow") # Preserve unknown fields - def to_transport(self) -> StdioTransport | FastMCPTransport: + def to_transport(self) -> StdioTransport: from fastmcp.client.transports import StdioTransport return StdioTransport( @@ -262,9 +250,7 @@ class RemoteMCPServer(BaseModel): extra="allow", arbitrary_types_allowed=True ) # Preserve unknown fields - def to_transport( - self, - ) -> StreamableHttpTransport | SSETransport | FastMCPTransport: + def to_transport(self) -> StreamableHttpTransport | SSETransport: from fastmcp.client.transports import ( SSETransport, StreamableHttpTransport, diff --git a/fastmcp_slim/fastmcp/prompts/__init__.py b/fastmcp_slim/fastmcp/prompts/__init__.py index b94b5952d..d1b866075 100644 --- a/fastmcp_slim/fastmcp/prompts/__init__.py +++ b/fastmcp_slim/fastmcp/prompts/__init__.py @@ -1,6 +1,14 @@ +import sys + from .function_prompt import FunctionPrompt, prompt from .base import Message, Prompt, PromptArgument, PromptMessage, PromptResult +# Backward compat: prompt.py was renamed to base.py to stop Pyright from resolving +# `from fastmcp.prompts import prompt` as the submodule instead of the decorator function. +# This shim keeps `from fastmcp.prompts.prompt import Prompt` working at runtime. +# Safe to remove once we're confident no external code imports from the old path. +sys.modules[f"{__name__}.prompt"] = sys.modules[f"{__name__}.base"] + __all__ = [ "FunctionPrompt", "Message", diff --git a/fastmcp_slim/fastmcp/prompts/base.py b/fastmcp_slim/fastmcp/prompts/base.py index b7e4fa3f6..f56243e57 100644 --- a/fastmcp_slim/fastmcp/prompts/base.py +++ b/fastmcp_slim/fastmcp/prompts/base.py @@ -3,12 +3,15 @@ from __future__ import annotations as _annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload import pydantic import pydantic_core if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution + from fastmcp.prompts.function_prompt import FunctionPrompt import mcp_types from mcp import GetPromptResult @@ -28,6 +31,7 @@ from pydantic.json_schema import SkipJsonSchema from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.tasks import TaskConfig, TaskMeta from fastmcp.utilities.types import ( FastMCPBaseModel, ) @@ -189,38 +193,6 @@ class PromptResult(pydantic.BaseModel): ) -class InputRequiredPromptResult(PromptResult): - """The full result of a single multi-round-trip prompt leg (SEP-2322). - - `InputRequiredResult` is a result type, not a `tools/call` feature: any - request may resolve to one. When a prompt returns an `InputRequiredResult` - from its body to ask the client for input, that ask is the legitimate - result of this `prompts/get` — so FastMCP wraps it in this `PromptResult` - subclass, mirroring `InputRequiredToolResult`, and it flows through the - middleware chain as an ordinary return value. - - Invariant: the wrapped `InputRequiredResult` is never rendered as prompt - messages. `messages` is always empty; the wire handler (`_on_get_prompt`) - reads `.input_required` and returns it to the runner. - """ - - input_required: mcp_types.InputRequiredResult = Field( - description="The client-input request this leg resolved to (SEP-2322)" - ) - - def __init__(self, input_required: mcp_types.InputRequiredResult) -> None: - # Bypass PromptResult's message-normalizing __init__: an input-required - # leg carries no messages (see the invariant above), and - # `input_required` is a required field PromptResult.__init__ can't set. - pydantic.BaseModel.__init__( - self, - messages=[], - description=None, - meta=None, - input_required=input_required, - ) - - class Prompt(FastMCPComponent): """A prompt template that can be rendered with parameters.""" @@ -270,6 +242,7 @@ class Prompt(FastMCPComponent): icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionPrompt: """Create a Prompt from a function. @@ -290,6 +263,7 @@ class Prompt(FastMCPComponent): icons=icons, tags=tags, meta=meta, + task=task, auth=auth, ) @@ -320,12 +294,6 @@ class Prompt(FastMCPComponent): if isinstance(raw_value, PromptResult): return raw_value - if isinstance(raw_value, mcp_types.InputRequiredResult): - # The prompt asked the client for input (SEP-2322). Wrap it so the - # ask travels the middleware chain as an ordinary result; the wire - # handler unwraps it. - return InputRequiredPromptResult(raw_value) - if isinstance(raw_value, str): return PromptResult(raw_value, description=self.description, meta=self.meta) @@ -348,19 +316,89 @@ class Prompt(FastMCPComponent): f"got {type(raw_value).__name__}" ) + @overload async def _render( self, arguments: dict[str, Any] | None = None, - ) -> PromptResult: - """Server entry point for prompt renders. + task_meta: None = None, + ) -> PromptResult: ... - The server calls this method instead of render() directly so that - subclasses can customize dispatch. For example, FastMCPProviderPrompt - overrides this to delegate to child-server middleware. + @overload + async def _render( + self, + arguments: dict[str, Any] | None, + task_meta: TaskMeta, + ) -> mcp_types.CreateTaskResult: ... + + async def _render( + self, + arguments: dict[str, Any] | None = None, + task_meta: TaskMeta | None = None, + ) -> PromptResult | mcp_types.CreateTaskResult: + """Server entry point that handles task routing. + + This allows ANY Prompt subclass to support background execution by setting + task_config.mode to "supported" or "required". The server calls this + method instead of render() directly. + + Args: + arguments: Prompt arguments + task_meta: If provided, execute as background task and return + CreateTaskResult. If None (default), execute synchronously and + return PromptResult. + + Returns: + PromptResult when task_meta is None. + CreateTaskResult when task_meta is provided. + + Subclasses can override this to customize task routing behavior. + For example, FastMCPProviderPrompt overrides to delegate to child + middleware without submitting to Docket. """ + from fastmcp.server.tasks.routing import check_background_task + + task_result = await check_background_task( + component=self, + task_type="prompt", + arguments=arguments, + task_meta=task_meta, + ) + if task_result: + return task_result + + # Synchronous execution result = await self.render(arguments) return self.convert_result(result) + def register_with_docket(self, docket: Docket) -> None: + """Register this prompt with docket for background execution.""" + if not self.task_config.supports_tasks(): + return + docket.register(self.render, names=[self.key]) + + async def add_to_docket( # type: ignore[override] + self, + docket: Docket, + arguments: dict[str, Any] | None, + *, + fn_key: str | None = None, + task_key: str | None = None, + **kwargs: Any, + ) -> Execution: + """Schedule this prompt for background execution via docket. + + Args: + docket: The Docket instance + arguments: Prompt arguments + fn_key: Function lookup key in Docket registry (defaults to self.key) + task_key: Redis storage key for the result + **kwargs: Additional kwargs passed to docket.add() + """ + lookup_key = fn_key or self.key + if task_key: + kwargs["key"] = task_key + return await docket.add(lookup_key, **kwargs)(arguments) + def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.component.type": "prompt", diff --git a/fastmcp_slim/fastmcp/prompts/function_prompt.py b/fastmcp_slim/fastmcp/prompts/function_prompt.py index e17d17d4e..959bebd06 100644 --- a/fastmcp_slim/fastmcp/prompts/function_prompt.py +++ b/fastmcp_slim/fastmcp/prompts/function_prompt.py @@ -9,6 +9,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from types import MethodType from typing import ( + TYPE_CHECKING, Any, Literal, Protocol, @@ -32,8 +33,13 @@ from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import get_cached_typeadapter +if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution + F = TypeVar("F", bound=Callable[..., Any]) logger = get_logger(__name__) @@ -60,6 +66,7 @@ class PromptMeta: icons: list[Icon] | None = None tags: set[str] | None = None meta: dict[str, Any] | None = None + task: bool | TaskConfig | None = None auth: AuthCheck | list[AuthCheck] | None = None enabled: bool = True @@ -83,6 +90,7 @@ class FunctionPrompt(Prompt): icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionPrompt: """Create a Prompt from a function. @@ -102,7 +110,7 @@ class FunctionPrompt(Prompt): # Check mutual exclusion individual_params_provided = any( x is not None - for x in [name, version, title, description, icons, tags, meta, auth] + for x in [name, version, title, description, icons, tags, meta, task, auth] ) if metadata is not None and individual_params_provided: @@ -121,6 +129,7 @@ class FunctionPrompt(Prompt): icons=icons, tags=tags, meta=meta, + task=task, auth=auth, ) @@ -143,6 +152,16 @@ class FunctionPrompt(Prompt): # docstring as the prompt description for callable class instances. outer_docstring = parse_docstring(fn) + # Normalize task to TaskConfig and validate + task_value = metadata.task + if task_value is None: + task_config = TaskConfig(mode="forbidden") + elif isinstance(task_value, bool): + task_config = TaskConfig.from_bool(task_value) + else: + task_config = task_value + task_config.validate_function(fn, func_name) + # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ @@ -217,10 +236,7 @@ class FunctionPrompt(Prompt): schema_str = json.dumps(param_schema, separators=(",", ":")) # Append schema info to description - schema_note = ( - "Provide a value matching the following JSON schema: " - f"{schema_str}. Encode non-string values as JSON." - ) + schema_note = f"Provide as a JSON string matching the following schema: {schema_str}" if arg_description: arg_description = f"{arg_description}\n\n{schema_note}" else: @@ -251,6 +267,7 @@ class FunctionPrompt(Prompt): tags=metadata.tags or set(), fn=wrapped_fn, meta=metadata.meta, + task_config=task_config, auth=metadata.auth, ) @@ -266,38 +283,26 @@ class FunctionPrompt(Prompt): if param_name in sig.parameters: param = sig.parameters[param_name] - if param.annotation == inspect.Parameter.empty or not isinstance( - param_value, str - ): + # If parameter has no annotation or annotation is str, pass as-is + if ( + param.annotation == inspect.Parameter.empty + or param.annotation is str + ) or not isinstance(param_value, str): converted_kwargs[param_name] = param_value else: # Try to convert string argument using type adapter try: adapter = get_cached_typeadapter(param.annotation) - # Preserve the MCP wire string when validation keeps it - # as a string. Non-string results still prefer JSON - # decoding so coercible types such as bytes and Path do - # not retain JSON quote characters. + # Try JSON parsing first for complex types try: - python_value = adapter.validate_python(param_value) - except (ValueError, TypeError, pydantic_core.ValidationError): converted_kwargs[param_name] = adapter.validate_json( param_value ) - else: - if isinstance(python_value, str): - converted_kwargs[param_name] = python_value - else: - try: - converted_kwargs[param_name] = ( - adapter.validate_json(param_value) - ) - except ( - ValueError, - TypeError, - pydantic_core.ValidationError, - ): - converted_kwargs[param_name] = python_value + except (ValueError, TypeError, pydantic_core.ValidationError): + # Fallback to direct validation + converted_kwargs[param_name] = adapter.validate_python( + param_value + ) except (ValueError, TypeError, pydantic_core.ValidationError) as e: # If conversion fails, provide informative error raise PromptError( @@ -362,6 +367,37 @@ class FunctionPrompt(Prompt): logger.exception(f"Error rendering prompt {self.name}") raise PromptError(f"Error rendering prompt {self.name!r}: {e}") from e + def register_with_docket(self, docket: Docket) -> None: + """Register this prompt with docket for background execution.""" + if not self.task_config.supports_tasks(): + return + docket.register(self.fn, names=[self.key]) + + async def add_to_docket( + self, + docket: Docket, + arguments: dict[str, Any] | None, + *, + fn_key: str | None = None, + task_key: str | None = None, + **kwargs: Any, + ) -> Execution: + """Schedule this prompt for background execution via docket. + + FunctionPrompt splats the arguments dict since .fn expects **kwargs. + + Args: + docket: The Docket instance + arguments: Prompt arguments + fn_key: Function lookup key in Docket registry (defaults to self.key) + task_key: Redis storage key for the result + **kwargs: Additional kwargs passed to docket.add() + """ + lookup_key = fn_key or self.key + if task_key: + kwargs["key"] = task_key + return await docket.add(lookup_key, **kwargs)(**(arguments or {})) + @overload def prompt(fn: F) -> F: ... @@ -375,6 +411,7 @@ def prompt( icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @overload @@ -388,6 +425,7 @@ def prompt( icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @@ -402,6 +440,7 @@ def prompt( icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Any: """Standalone decorator to mark a function as an MCP prompt. @@ -424,6 +463,7 @@ def prompt( icons=icons, tags=tags, meta=meta, + task=task, auth=auth, ) target = fn.__func__ if isinstance(fn, staticmethod | MethodType) else fn diff --git a/fastmcp_slim/fastmcp/resources/__init__.py b/fastmcp_slim/fastmcp/resources/__init__.py index b0e5b4524..cbe819c95 100644 --- a/fastmcp_slim/fastmcp/resources/__init__.py +++ b/fastmcp_slim/fastmcp/resources/__init__.py @@ -1,3 +1,5 @@ +import sys + from .function_resource import FunctionResource, resource from .base import Resource, ResourceContent, ResourceResult from .security import ResourceSecurity @@ -24,3 +26,9 @@ __all__ = [ "TextResource", "resource", ] + +# Backward compat: resource.py was renamed to base.py to stop Pyright from resolving +# `from fastmcp.resources import resource` as the submodule instead of the decorator function. +# This shim keeps `from fastmcp.resources.resource import Resource` working at runtime. +# Safe to remove once we're confident no external code imports from the old path. +sys.modules[f"{__name__}.resource"] = sys.modules[f"{__name__}.base"] diff --git a/fastmcp_slim/fastmcp/resources/base.py b/fastmcp_slim/fastmcp/resources/base.py index 30ed132ea..b4e4d0d1d 100644 --- a/fastmcp_slim/fastmcp/resources/base.py +++ b/fastmcp_slim/fastmcp/resources/base.py @@ -5,11 +5,14 @@ from __future__ import annotations import base64 import json from collections.abc import Callable -from typing import TYPE_CHECKING, Annotated, Any, ClassVar +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload import mcp_types if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution + from fastmcp.resources.function_resource import FunctionResource import pydantic @@ -29,6 +32,7 @@ from typing_extensions import Self from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent +from fastmcp.utilities.tasks import TaskConfig, TaskMeta class ResourceContent(pydantic.BaseModel): @@ -210,127 +214,6 @@ class ResourceResult(pydantic.BaseModel): ) -class InputRequiredResourceResult(ResourceResult): - """The full result of a single multi-round-trip resource read (SEP-2322). - - `InputRequiredResult` is a result type, not a `tools/call` feature: any - request may resolve to one. When a resource or resource template returns an - `InputRequiredResult` from its body to ask the client for input, that ask is - the legitimate result of this `resources/read` — so FastMCP wraps it in this - `ResourceResult` subclass, mirroring `InputRequiredToolResult` and - `InputRequiredPromptResult`, and it flows through the middleware chain as an - ordinary return value. - - Invariant: the wrapped `InputRequiredResult` is never serialized as resource - contents. `contents` is always empty; the wire handler (`_on_read_resource`) - reads `.input_required` and returns it to the runner. - """ - - input_required: mcp_types.InputRequiredResult = pydantic.Field( - description="The client-input request this read resolved to (SEP-2322)" - ) - - def __init__(self, input_required: mcp_types.InputRequiredResult) -> None: - # Bypass ResourceResult's content-normalizing __init__: an - # input-required read carries no contents (see the invariant above), and - # `input_required` is a required field ResourceResult.__init__ can't set. - pydantic.BaseModel.__init__( - self, contents=[], meta=None, input_required=input_required - ) - - -def _public_content_meta(meta: dict[str, Any] | None) -> dict[str, Any] | None: - """Strip FastMCP's internal bookkeeping out of component meta. - - Component `meta` carries private entries under the `fastmcp` namespace - (e.g. `_internal.visibility`) that must never reach the wire. Listings - already filter these via `FastMCPComponent.get_meta()`; content items - served by `resources/read` need the same treatment. - - Returns None when nothing public remains, so resources without user - metadata keep an absent `_meta` rather than an empty object. - """ - if not meta: - return None - - public = dict(meta) - fastmcp_meta = public.get("fastmcp") - if isinstance(fastmcp_meta, dict): - public_fastmcp = { - key: value for key, value in fastmcp_meta.items() if not key.startswith("_") - } - if public_fastmcp: - public["fastmcp"] = public_fastmcp - else: - public.pop("fastmcp") - - return public or None - - -def convert_raw_to_resource_result( - raw_value: Any, - *, - mime_type: str | None, - meta: dict[str, Any] | None, -) -> ResourceResult: - """Wrap a user function's return value in a ResourceResult. - - Shared by `Resource` and `ResourceTemplate` so both honor the MIME type - the component declares in listings. A component that advertises - `text/csv` must not serve `text/plain` on read. - - Args: - raw_value: The value returned by the user's function. - mime_type: The component's declared MIME type, forwarded to content items. - meta: Component-level meta (e.g. `ui` metadata for MCP Apps CSP/permissions) - propagated to each content item. - """ - if isinstance(raw_value, ResourceResult): - return raw_value - - if isinstance(raw_value, mcp_types.InputRequiredResult): - # The resource asked the client for input (SEP-2322). Wrap it so the - # ask travels the middleware chain as an ordinary result; the wire - # handler unwraps it. - return InputRequiredResourceResult(raw_value) - - meta = _public_content_meta(meta) - - # For plain str/bytes returns, wrap in ResourceContent with the - # component's MIME type and meta so the wire response carries the - # correct type and metadata (e.g. CSP for MCP Apps). - if isinstance(raw_value, (str, bytes)): - return ResourceResult( - [ResourceContent(raw_value, mime_type=mime_type, meta=meta)] - ) - - # For JSON-native types (dict, list, tuple, int, float, bool, None), - # serialize and wrap in ResourceContent with the component's meta, - # matching the str/bytes path above so CSP/permissions propagate. - # Exclude list[ResourceContent] which should go through ResourceResult - # normalization below. - if ( - isinstance(raw_value, dict | list | tuple | int | float | bool) - or raw_value is None - ) and not ( - isinstance(raw_value, list) - and raw_value - and isinstance(raw_value[0], ResourceContent) - ): - return ResourceResult( - [ - ResourceContent( - json.dumps(raw_value), - mime_type=mime_type or "application/json", - meta=meta, - ) - ] - ) - - # All other types fall through to ResourceResult for error handling - return ResourceResult(raw_value) - - class Resource(FastMCPComponent): """Base class for all resources.""" @@ -370,6 +253,7 @@ class Resource(FastMCPComponent): tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResource: from fastmcp.resources.function_resource import ( @@ -388,6 +272,7 @@ class Resource(FastMCPComponent): tags=tags, annotations=annotations, meta=meta, + task=task, auth=auth, ) @@ -439,18 +324,80 @@ class Resource(FastMCPComponent): MCP Apps CSP/permissions) is propagated to each content item so that hosts can read it from the ``resources/read`` response. """ - return convert_raw_to_resource_result( - raw_value, mime_type=self.mime_type, meta=self.meta - ) + if isinstance(raw_value, ResourceResult): + return raw_value - async def _read(self) -> ResourceResult: - """Server entry point for resource reads. + # For plain str/bytes returns, wrap in ResourceContent with the + # resource's MIME type and component meta so the wire response + # carries the correct type and metadata (e.g. CSP for MCP Apps). + if isinstance(raw_value, (str, bytes)): + return ResourceResult( + [ResourceContent(raw_value, mime_type=self.mime_type, meta=self.meta)] + ) - The server calls this method instead of ``read()`` directly so that - subclasses can customize dispatch. For example, - ``FastMCPProviderResource`` overrides this to delegate to child-server - middleware. + # For JSON-native types (dict, list, tuple, int, float, bool, None), + # serialize and wrap in ResourceContent with the component's meta, + # matching the str/bytes path above so CSP/permissions propagate. + # Exclude list[ResourceContent] which should go through ResourceResult + # normalization below. + if ( + isinstance(raw_value, dict | list | tuple | int | float | bool) + or raw_value is None + ) and not ( + isinstance(raw_value, list) + and raw_value + and isinstance(raw_value[0], ResourceContent) + ): + return ResourceResult( + [ + ResourceContent( + json.dumps(raw_value), + mime_type=self.mime_type or "application/json", + meta=self.meta, + ) + ] + ) + + # All other types fall through to ResourceResult for error handling + return ResourceResult(raw_value) + + @overload + async def _read(self, task_meta: None = None) -> ResourceResult: ... + + @overload + async def _read(self, task_meta: TaskMeta) -> mcp_types.CreateTaskResult: ... + + async def _read( + self, task_meta: TaskMeta | None = None + ) -> ResourceResult | mcp_types.CreateTaskResult: + """Server entry point that handles task routing. + + This allows ANY Resource subclass to support background execution by setting + task_config.mode to "supported" or "required". The server calls this + method instead of read() directly. + + Args: + task_meta: If provided, execute as a background task and return + CreateTaskResult. If None (default), execute synchronously and + return ResourceResult. + + Returns: + ResourceResult when task_meta is None. + CreateTaskResult when task_meta is provided. + + Subclasses can override this to customize task routing behavior. + For example, FastMCPProviderResource overrides to delegate to child + middleware without submitting to Docket. """ + from fastmcp.server.tasks.routing import check_background_task + + task_result = await check_background_task( + component=self, task_type="resource", arguments=None, task_meta=task_meta + ) + if task_result: + return task_result + + # Synchronous execution - convert result to ResourceResult result = await self.read() return self.convert_result(result) @@ -482,6 +429,33 @@ class Resource(FastMCPComponent): base_key = self.make_key(str(self.uri)) return f"{base_key}@{self.version or ''}" + def register_with_docket(self, docket: Docket) -> None: + """Register this resource with docket for background execution.""" + if not self.task_config.supports_tasks(): + return + docket.register(self.read, names=[self.key]) + + async def add_to_docket( # type: ignore[override] + self, + docket: Docket, + *, + fn_key: str | None = None, + task_key: str | None = None, + **kwargs: Any, + ) -> Execution: + """Schedule this resource for background execution via docket. + + Args: + docket: The Docket instance + fn_key: Function lookup key in Docket registry (defaults to self.key) + task_key: Redis storage key for the result + **kwargs: Additional kwargs passed to docket.add() + """ + lookup_key = fn_key or self.key + if task_key: + kwargs["key"] = task_key + return await docket.add(lookup_key, **kwargs)() + def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.component.type": "resource", diff --git a/fastmcp_slim/fastmcp/resources/function_resource.py b/fastmcp_slim/fastmcp/resources/function_resource.py index 6d7612939..aa71508d9 100644 --- a/fastmcp_slim/fastmcp/resources/function_resource.py +++ b/fastmcp_slim/fastmcp/resources/function_resource.py @@ -8,6 +8,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from types import MethodType from typing import ( + TYPE_CHECKING, Any, Literal, Protocol, @@ -32,6 +33,11 @@ from fastmcp.utilities.async_utils import ( ) from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.mime import resolve_ui_mime_type +from fastmcp.utilities.tasks import TaskConfig + +if TYPE_CHECKING: + from docket import Docket + F = TypeVar("F", bound=Callable[..., Any]) @@ -60,6 +66,7 @@ class ResourceMeta: mime_type: str | None = None annotations: Annotations | None = None meta: dict[str, Any] | None = None + task: bool | TaskConfig | None = None auth: AuthCheck | list[AuthCheck] | None = None enabled: bool = True security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY @@ -97,6 +104,7 @@ class FunctionResource(Resource): tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResource: """Create a FunctionResource from a function. @@ -123,6 +131,7 @@ class FunctionResource(Resource): tags, annotations, meta, + task, auth, ] ) @@ -150,6 +159,7 @@ class FunctionResource(Resource): mime_type=mime_type, annotations=annotations, meta=meta, + task=task, auth=auth, ) @@ -160,6 +170,16 @@ class FunctionResource(Resource): metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__ ) + # Normalize task to TaskConfig and validate + task_value = metadata.task + if task_value is None: + task_config = TaskConfig(mode="forbidden") + elif isinstance(task_value, bool): + task_config = TaskConfig.from_bool(task_value) + else: + task_config = task_value + task_config.validate_function(fn, func_name) + # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ @@ -195,6 +215,7 @@ class FunctionResource(Resource): tags=metadata.tags or set(), annotations=metadata.annotations, meta=metadata.meta, + task_config=task_config, auth=metadata.auth, ) @@ -219,6 +240,12 @@ class FunctionResource(Resource): return result + def register_with_docket(self, docket: Docket) -> None: + """Register this resource with docket for background execution.""" + if not self.task_config.supports_tasks(): + return + docket.register(self.fn, names=[self.key]) + def resource( uri: str, @@ -232,6 +259,7 @@ def resource( tags: set[str] | None = None, annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY, ) -> Callable[[F], F]: @@ -261,6 +289,7 @@ def resource( mime_type=mime_type, annotations=annotations, meta=meta, + task=task, auth=auth, security=security, ) diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index 866eb940a..00cceaeec 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -6,23 +6,24 @@ import functools import inspect import re from collections.abc import Callable -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, overload from urllib.parse import parse_qs, quote, unquote +import mcp_types from mcp_types import Annotations, Icon +from pydantic.json_schema import SkipJsonSchema + +if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution from mcp_types import ResourceTemplate as SDKResourceTemplate from pydantic import ( Field, field_validator, validate_call, ) -from pydantic.json_schema import SkipJsonSchema -from fastmcp.resources.base import ( - Resource, - ResourceResult, - convert_raw_to_resource_result, -) +from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.security import ( INHERIT_SECURITY, InheritSecurity, @@ -32,6 +33,7 @@ from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.mime import resolve_ui_mime_type +from fastmcp.utilities.tasks import TaskConfig, TaskMeta from fastmcp.utilities.types import get_cached_typeadapter @@ -229,6 +231,7 @@ class ResourceTemplate(FastMCPComponent): tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY, ) -> FunctionResourceTemplate: @@ -244,6 +247,7 @@ class ResourceTemplate(FastMCPComponent): tags=tags, annotations=annotations, meta=meta, + task=task, auth=auth, security=security, ) @@ -274,21 +278,58 @@ class ResourceTemplate(FastMCPComponent): 2. In tasks_result_handler() to convert Docket task results to ResourceResult Handles ResourceResult passthrough and converts raw values using - ResourceResult's normalization. The template's own ``mime_type`` is - forwarded so that reads match the MIME type the template advertises - in ``resources/templates/list``. + ResourceResult's normalization. """ - return convert_raw_to_resource_result( - raw_value, mime_type=self.mime_type, meta=self.meta + if isinstance(raw_value, ResourceResult): + return raw_value + + # ResourceResult.__init__ handles all normalization + return ResourceResult(raw_value) + + @overload + async def _read( + self, uri: str, params: dict[str, Any], task_meta: None = None + ) -> ResourceResult: ... + + @overload + async def _read( + self, uri: str, params: dict[str, Any], task_meta: TaskMeta + ) -> mcp_types.CreateTaskResult: ... + + async def _read( + self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None + ) -> ResourceResult | mcp_types.CreateTaskResult: + """Server entry point that handles task routing. + + This allows ANY ResourceTemplate subclass to support background execution + by setting task_config.mode to "supported" or "required". The server calls + this method instead of create_resource()/read() directly. + + Args: + uri: The concrete URI being read + params: Template parameters extracted from the URI + task_meta: If provided, execute as a background task and return + CreateTaskResult. If None (default), execute synchronously and + return ResourceResult. + + Returns: + ResourceResult when task_meta is None. + CreateTaskResult when task_meta is provided. + + Subclasses can override this to customize task routing behavior. + For example, FastMCPProviderResourceTemplate overrides to delegate to child + middleware without submitting to Docket. + """ + from fastmcp.server.tasks.routing import check_background_task + + task_result = await check_background_task( + component=self, task_type="template", arguments=params, task_meta=task_meta ) + if task_result: + return task_result - async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult: - """Server entry point for template reads. - - The server calls this instead of create_resource()/read() directly so - subclasses can customize dispatch (e.g. FastMCPProviderResourceTemplate - delegates to child-server middleware). - """ + # Synchronous execution - create resource and read directly + # Call resource.read() not resource._read() to avoid task routing on ephemeral resource resource = await self.create_resource(uri, params) result = await resource.read() return self.convert_result(result) @@ -342,6 +383,35 @@ class ResourceTemplate(FastMCPComponent): base_key = self.make_key(self.uri_template) return f"{base_key}@{self.version or ''}" + def register_with_docket(self, docket: Docket) -> None: + """Register this template with docket for background execution.""" + if not self.task_config.supports_tasks(): + return + docket.register(self.read, names=[self.key]) + + async def add_to_docket( # type: ignore[override] + self, + docket: Docket, + params: dict[str, Any], + *, + fn_key: str | None = None, + task_key: str | None = None, + **kwargs: Any, + ) -> Execution: + """Schedule this template for background execution via docket. + + Args: + docket: The Docket instance + params: Template parameters + fn_key: Function lookup key in Docket registry (defaults to self.key) + task_key: Redis storage key for the result + **kwargs: Additional kwargs passed to docket.add() + """ + lookup_key = fn_key or self.key + if task_key: + kwargs["key"] = task_key + return await docket.add(lookup_key, **kwargs)(params) + def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.component.type": "resource_template", @@ -354,13 +424,44 @@ class FunctionResourceTemplate(ResourceTemplate): fn: SkipJsonSchema[Callable[..., Any]] - async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult: + @overload + async def _read( + self, uri: str, params: dict[str, Any], task_meta: None = None + ) -> ResourceResult: ... + + @overload + async def _read( + self, uri: str, params: dict[str, Any], task_meta: TaskMeta + ) -> mcp_types.CreateTaskResult: ... + + async def _read( + self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None + ) -> ResourceResult | mcp_types.CreateTaskResult: """Optimized server entry point that skips ephemeral resource creation. For FunctionResourceTemplate, we can call read() directly instead of creating a temporary resource, which is more efficient. + + Args: + uri: The concrete URI being read + params: Template parameters extracted from the URI + task_meta: If provided, execute as a background task and return + CreateTaskResult. If None (default), execute synchronously and + return ResourceResult. + + Returns: + ResourceResult when task_meta is None. + CreateTaskResult when task_meta is provided. """ - # Call read() directly, skip resource creation + from fastmcp.server.tasks.routing import check_background_task + + task_result = await check_background_task( + component=self, task_type="template", arguments=params, task_meta=task_meta + ) + if task_result: + return task_result + + # Synchronous execution - call read() directly, skip resource creation result = await self.read(arguments=params) return self.convert_result(result) @@ -383,6 +484,7 @@ class FunctionResourceTemplate(ResourceTemplate): meta=self.meta, title=self.title, icons=self.icons, + task=self.task_config, auth=self.auth, ) @@ -425,6 +527,37 @@ class FunctionResourceTemplate(ResourceTemplate): return result + def register_with_docket(self, docket: Docket) -> None: + """Register this template with docket for background execution.""" + if not self.task_config.supports_tasks(): + return + docket.register(self.fn, names=[self.key]) + + async def add_to_docket( + self, + docket: Docket, + params: dict[str, Any], + *, + fn_key: str | None = None, + task_key: str | None = None, + **kwargs: Any, + ) -> Execution: + """Schedule this template for background execution via docket. + + FunctionResourceTemplate splats the params dict since .fn expects **kwargs. + + Args: + docket: The Docket instance + params: Template parameters + fn_key: Function lookup key in Docket registry (defaults to self.key) + task_key: Redis storage key for the result + **kwargs: Additional kwargs passed to docket.add() + """ + lookup_key = fn_key or self.key + if task_key: + kwargs["key"] = task_key + return await docket.add(lookup_key, **kwargs)(**params) + @classmethod def from_function( cls, @@ -439,6 +572,7 @@ class FunctionResourceTemplate(ResourceTemplate): tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY, ) -> FunctionResourceTemplate: @@ -535,6 +669,15 @@ class FunctionResourceTemplate(ResourceTemplate): description = description if description is not None else inspect.getdoc(fn) + # Normalize task to TaskConfig and validate + if task is None: + task_config = TaskConfig(mode="forbidden") + elif isinstance(task, bool): + task_config = TaskConfig.from_bool(task) + else: + task_config = task + task_config.validate_function(fn, func_name) + # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ @@ -569,6 +712,7 @@ class FunctionResourceTemplate(ResourceTemplate): tags=tags or set(), annotations=annotations, meta=meta, + task_config=task_config, auth=auth, security=security, ) diff --git a/fastmcp_slim/fastmcp/server/__init__.py b/fastmcp_slim/fastmcp/server/__init__.py index 63c1f0351..d6edbc4f1 100644 --- a/fastmcp_slim/fastmcp/server/__init__.py +++ b/fastmcp_slim/fastmcp/server/__init__.py @@ -1,31 +1,17 @@ import importlib -from typing import TYPE_CHECKING from fastmcp import _install_hints -if TYPE_CHECKING: - from .context import Context as Context - from .server import FastMCP as FastMCP - from .server import create_proxy as create_proxy +try: + from .context import Context + from .server import FastMCP, create_proxy +except ImportError as exc: + raise ImportError(_install_hints.SERVER_SUPPORT) from exc def __getattr__(name: str) -> object: - if name in {"context", "dependencies"}: - return importlib.import_module(f"fastmcp.server.{name}") - if name == "Context": - try: - from .context import Context - except ImportError as exc: - raise ImportError(_install_hints.SERVER_SUPPORT) from exc - - return Context - if name in {"FastMCP", "create_proxy"}: - try: - from .server import FastMCP, create_proxy - except ImportError as exc: - raise ImportError(_install_hints.SERVER_SUPPORT) from exc - - return FastMCP if name == "FastMCP" else create_proxy + if name == "dependencies": + return importlib.import_module("fastmcp.server.dependencies") raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/fastmcp_slim/fastmcp/server/auth/__init__.py b/fastmcp_slim/fastmcp/server/auth/__init__.py index 8c1e2631d..cd6a300ad 100644 --- a/fastmcp_slim/fastmcp/server/auth/__init__.py +++ b/fastmcp_slim/fastmcp/server/auth/__init__.py @@ -8,17 +8,15 @@ from .auth import ( AccessToken, AuthProvider, ) -from fastmcp.utilities.authorization import ( +from .authorization import ( AuthCheck, AuthContext, - require_roles, require_scopes, restrict_tag, run_auth_checks, ) if TYPE_CHECKING: - from .identity_assertion import IdentityAssertion as IdentityAssertion from .oauth_proxy import OAuthProxy as OAuthProxy from .oidc_proxy import OIDCProxy as OIDCProxy from .providers.debug import DebugTokenVerifier as DebugTokenVerifier @@ -46,10 +44,6 @@ def __getattr__(name: str) -> object: from .providers.jwt import StaticTokenVerifier return StaticTokenVerifier - if name == "IdentityAssertion": - from .identity_assertion import IdentityAssertion - - return IdentityAssertion if name == "OAuthProxy": from .oauth_proxy import OAuthProxy @@ -67,7 +61,6 @@ __all__ = [ "AuthContext", "AuthProvider", "DebugTokenVerifier", - "IdentityAssertion", "JWTVerifier", "MultiAuth", "OAuthProvider", @@ -76,7 +69,6 @@ __all__ = [ "RemoteAuthProvider", "StaticTokenVerifier", "TokenVerifier", - "require_roles", "require_scopes", "restrict_tag", "run_auth_checks", diff --git a/fastmcp_slim/fastmcp/server/auth/auth.py b/fastmcp_slim/fastmcp/server/auth/auth.py index 6e3b13f42..dc203fbf4 100644 --- a/fastmcp_slim/fastmcp/server/auth/auth.py +++ b/fastmcp_slim/fastmcp/server/auth/auth.py @@ -4,7 +4,6 @@ import json from typing import TYPE_CHECKING, Any from urllib.parse import urlparse -from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.handlers.token import TokenErrorResponse from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler from mcp.server.auth.json_response import PydanticJSONResponse @@ -22,16 +21,13 @@ from mcp.server.auth.provider import ( ) from mcp.server.auth.provider import ( AuthorizationCode, - IdentityAssertionParams, OAuthAuthorizationServerProvider, RefreshToken, - TokenError, ) from mcp.server.auth.provider import ( TokenVerifier as TokenVerifierProtocol, ) from mcp.server.auth.routes import ( - build_metadata, cors_middleware, create_auth_routes, create_protected_resource_routes, @@ -40,7 +36,7 @@ from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) -from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull +from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyHttpUrl, Field from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware @@ -78,22 +74,7 @@ class TokenHandler(_SDKTokenHandler): """ async def handle(self, request: Any): - """Wrap SDK handle() and transform auth error responses. - - The SEP-990 jwt-bearer (ID-JAG) grant is dispatched here rather than by - the SDK. The SDK requires a confidential client (a stored client_secret) - before it will call `exchange_identity_assertion`. FastMCP OAuth-proxy - clients are always public (`token_endpoint_auth_method="none"`, no stored - secret), and in the proxy trust model the ID-JAG — validated against a - trusted issuer — is the authoritative grant, not a per-client secret. - So when identity assertion is enabled we authenticate the client and - dispatch the grant ourselves, without the SDK's confidential precondition. - """ - if self.identity_assertion_enabled: - id_jag_response = await self._maybe_handle_id_jag(request) - if id_jag_response is not None: - return id_jag_response - + """Wrap SDK handle() and transform auth error responses.""" response = await super().handle(request) # Transform 401 unauthorized_client -> invalid_client @@ -137,80 +118,6 @@ class TokenHandler(_SDKTokenHandler): return response - async def _maybe_handle_id_jag(self, request: Request): - """Dispatch the SEP-990 jwt-bearer grant, or None to fall through. - - Returns a response for the jwt-bearer grant (ID-JAG), or None when the - request is not a jwt-bearer grant so the SDK handler runs normally. - """ - form_data = await request.form() - if form_data.get("grant_type") != JWT_BEARER_GRANT_TYPE: - return None - - try: - client_info = await self.client_authenticator.authenticate_request(request) - except AuthenticationError as e: - return PydanticJSONResponse( - content=TokenErrorResponse( - error="invalid_client", - error_description=e.message, - ), - status_code=401, - headers={"Cache-Control": "no-store", "Pragma": "no-cache"}, - ) - - # Dispatching the jwt-bearer grant ourselves bypasses the SDK's - # `grant_type not in client_info.grant_types` check, so enforce it here: - # a client may only use the ID-JAG grant if it registered for it. On the - # proxy, DCR adds this grant type to registered clients when identity - # assertion is enabled, so legitimately-registered clients are accepted - # while clients registered only for authorization_code/refresh_token are not. - if JWT_BEARER_GRANT_TYPE not in client_info.grant_types: - return self.response( - TokenErrorResponse( - error="unsupported_grant_type", - error_description=( - "Unsupported grant type (supported grant types are " - f"{client_info.grant_types})" - ), - ) - ) - - assertion = form_data.get("assertion") - if not isinstance(assertion, str) or not assertion: - return self.response( - TokenErrorResponse( - error="invalid_request", - error_description="Missing assertion", - ) - ) - - scope = form_data.get("scope") - resource = form_data.get("resource") - params = IdentityAssertionParams( - assertion=assertion, - scopes=scope.split(" ") if isinstance(scope, str) and scope else None, - resource=resource if isinstance(resource, str) else None, - ) - - try: - tokens = await self.provider.exchange_identity_assertion( - client_info, params - ) - except TokenError as e: - # Per MCP spec, invalid/expired grants MUST return 401 (the SDK path is - # transformed the same way in handle()). - status_code = 401 if e.error == "invalid_grant" else 400 - return PydanticJSONResponse( - content=TokenErrorResponse( - error=e.error, error_description=e.error_description - ), - status_code=status_code, - headers={"Cache-Control": "no-store", "Pragma": "no-cache"}, - ) - - return self.response(tokens) - # Expected assertion type for private_key_jwt JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" @@ -350,26 +257,6 @@ class AuthProvider(TokenVerifierProtocol): """ raise NotImplementedError("Subclasses must implement verify_token") - @property - def scopes_supported(self) -> list[str]: - """Scopes advertised in protected resource metadata.""" - return self.required_scopes - - @property - def challenge_scopes(self) -> list[str]: - """Scopes clients must request to access this resource.""" - return self.get_challenge_scopes() - - def get_challenge_scopes( - self, required_scopes: list[str] | None = None - ) -> list[str]: - """Translate validation scopes into scopes clients should request. - - Providers whose authorization server uses a different scope format can - override this method to translate any effective set of validation scopes. - """ - return self.required_scopes if required_scopes is None else required_scopes - def set_mcp_path(self, mcp_path: str | None) -> None: """Set the MCP endpoint path and compute resource URL. @@ -507,6 +394,17 @@ class TokenVerifier(AuthProvider): required_scopes=required_scopes, ) + @property + def scopes_supported(self) -> list[str]: + """Scopes to advertise in OAuth metadata. + + Defaults to required_scopes. Override in subclasses when the + advertised scopes differ from the validation scopes (e.g., Azure AD + where tokens contain short-form scopes but clients request full URI + scopes). + """ + return self.required_scopes or [] + async def verify_token(self, token: str) -> AccessToken | None: """Verify a bearer token and return access info if valid.""" raise NotImplementedError("Subclasses must implement verify_token") @@ -536,7 +434,6 @@ class RemoteAuthProvider(AuthProvider): resource_base_url: AnyHttpUrl | str | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, - challenge_scopes: list[str] | None = None, ): """Initialize the remote auth provider. @@ -554,8 +451,6 @@ class RemoteAuthProvider(AuthProvider): uses the token verifier's scopes_supported property. Use this when the scopes clients request differ from the scopes that appear in tokens (e.g., Azure AD full URI scopes vs short-form). - challenge_scopes: Request-facing form of the required validation scopes. - When omitted, scope translation delegates to the token verifier. resource_name: Optional name for the protected resource resource_documentation: Optional documentation URL for the protected resource """ @@ -567,34 +462,9 @@ class RemoteAuthProvider(AuthProvider): self.token_verifier = token_verifier self.authorization_servers = authorization_servers self._scopes_supported = scopes_supported - self._challenge_scopes = challenge_scopes self.resource_name = resource_name self.resource_documentation = resource_documentation - @property - def scopes_supported(self) -> list[str]: - """Scopes advertised in protected resource metadata.""" - if self._scopes_supported is not None: - return self._scopes_supported - return self.token_verifier.scopes_supported - - def get_challenge_scopes( - self, required_scopes: list[str] | None = None - ) -> list[str]: - """Translate effective validation scopes for the authorization server.""" - effective_scopes = ( - self.required_scopes if required_scopes is None else required_scopes - ) - if ( - effective_scopes == self.required_scopes - and self._challenge_scopes is not None - ): - return self._challenge_scopes - translator = getattr(self.token_verifier, "get_challenge_scopes", None) - if translator is None: - return effective_scopes - return translator(effective_scopes) - async def verify_token(self, token: str) -> AccessToken | None: """Verify token using the configured token verifier.""" return await self.token_verifier.verify_token(token) @@ -624,7 +494,11 @@ class RemoteAuthProvider(AuthProvider): create_protected_resource_routes( resource_url=resource_url, authorization_servers=self.authorization_servers, - scopes_supported=self.scopes_supported, + scopes_supported=( + self._scopes_supported + if self._scopes_supported is not None + else self.token_verifier.scopes_supported + ), resource_name=self.resource_name, resource_documentation=self.resource_documentation, ) @@ -646,22 +520,10 @@ class MultiAuth(AuthProvider): Example: ```python - from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, JWTVerifier, OAuthProxy - upstream = OAuthProxy( - upstream_authorization_endpoint="https://login.example.com/oauth/authorize", - upstream_token_endpoint="https://login.example.com/oauth/token", - upstream_client_id="my-app", - upstream_client_secret="secret", - token_verifier=JWTVerifier( - jwks_uri="https://login.example.com/.well-known/jwks.json" - ), - base_url="https://my-server.com", - ) - auth = MultiAuth( - server=upstream, + server=OAuthProxy(issuer_url="https://login.example.com/..."), verifiers=[JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json")], ) mcp = FastMCP("my-server", auth=auth) @@ -726,28 +588,6 @@ class MultiAuth(AuthProvider): self._sources.append(self.server) self._sources.extend(self.verifiers) - @property - def scopes_supported(self) -> list[str]: - """Scopes advertised by the delegated auth server.""" - if self.server is not None: - return self.server.scopes_supported - return self.required_scopes - - def get_challenge_scopes( - self, required_scopes: list[str] | None = None - ) -> list[str]: - """Translate effective scopes through an unambiguous auth source.""" - effective_scopes = ( - self.required_scopes if required_scopes is None else required_scopes - ) - if self.server is not None: - return self.server.get_challenge_scopes(effective_scopes) - if len(self.verifiers) == 1: - translator = getattr(self.verifiers[0], "get_challenge_scopes", None) - if translator is not None: - return translator(effective_scopes) - return effective_scopes - async def verify_token(self, token: str) -> AccessToken | None: """Verify a token by trying the server, then each verifier in order. @@ -851,8 +691,7 @@ class OAuthProvider( ): logger.info( f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. " - f"Ensure well-known routes are accessible at root " - f"({str(self.issuer_url).rstrip('/')}/.well-known/). " + f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). " f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers" ) @@ -881,16 +720,6 @@ class OAuthProvider( """ return await self.load_access_token(token) - @property - def scopes_supported(self) -> list[str]: - """Scopes advertised by this authorization server.""" - if ( - self.client_registration_options - and self.client_registration_options.valid_scopes - ): - return self.client_registration_options.valid_scopes - return self.required_scopes - def get_routes( self, mcp_path: str | None = None, @@ -907,10 +736,10 @@ class OAuthProvider( # Configure resource URL before creating routes self.set_mcp_path(mcp_path) - # Create standard OAuth authorization server routes. Pass base_url so - # the SDK mounts operational routes and declares endpoint URLs where - # they're actually accessible; the metadata route is replaced below so - # that the advertised `issuer` reports issuer_url instead. + # Create standard OAuth authorization server routes + # Pass base_url as issuer_url to ensure metadata declares endpoints where + # they're actually accessible (operational routes are mounted at + # base_url) assert self.base_url is not None # typing check assert ( self.issuer_url is not None @@ -929,35 +758,6 @@ class OAuthProvider( oauth_routes: list[Route] = [] for route in sdk_routes: if ( - isinstance(route, Route) - and route.path == "/.well-known/oauth-authorization-server" - ): - # The SDK bakes the metadata into the handler when it builds the - # route, and derives both `issuer` and every endpoint URL from a - # single argument. Rebuild it here so the endpoints stay on - # base_url (where the routes are mounted) while `issuer` - # reports issuer_url — the identifier clients used for RFC 8414 - # discovery, which §3.3 requires the metadata to match. - metadata = build_metadata( - self.base_url, - self.service_documentation_url, - self.client_registration_options or ClientRegistrationOptions(), - self.revocation_options or RevocationOptions(), - ) - metadata.issuer = self.issuer_url - metadata_handler = MetadataHandler(metadata) - oauth_routes.append( - Route( - path=route.path, - endpoint=cors_middleware( - metadata_handler.handle, ["GET", "OPTIONS"] - ), - methods=route.methods or ["GET", "OPTIONS"], - name=route.name, - include_in_schema=route.include_in_schema, - ) - ) - elif ( isinstance(route, Route) and route.path == "/token" and route.methods is not None @@ -981,10 +781,16 @@ class OAuthProvider( # Add protected resource routes if this server is also acting as a resource server if self._resource_url: + supported_scopes = ( + self.client_registration_options.valid_scopes + if self.client_registration_options + and self.client_registration_options.valid_scopes + else self.required_scopes + ) protected_routes = create_protected_resource_routes( resource_url=self._resource_url, authorization_servers=[self.issuer_url], - scopes_supported=self.scopes_supported, + scopes_supported=supported_scopes, ) oauth_routes.extend(protected_routes) diff --git a/fastmcp_slim/fastmcp/server/auth/authorization.py b/fastmcp_slim/fastmcp/server/auth/authorization.py new file mode 100644 index 000000000..ba252f9d8 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/auth/authorization.py @@ -0,0 +1,17 @@ +"""Backward-compatible exports for component authorization primitives.""" + +from fastmcp.utilities.authorization import ( + AuthCheck, + AuthContext, + require_scopes, + restrict_tag, + run_auth_checks, +) + +__all__ = [ + "AuthCheck", + "AuthContext", + "require_scopes", + "restrict_tag", + "run_auth_checks", +] diff --git a/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py b/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py index 6b7e6eab8..f91b266b7 100644 --- a/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py +++ b/fastmcp_slim/fastmcp/server/auth/handlers/authorize.py @@ -15,7 +15,6 @@ from __future__ import annotations import json from typing import TYPE_CHECKING -from urllib.parse import parse_qs, urlparse from mcp.server.auth.handlers.authorize import ( AuthorizationHandler as SDKAuthorizationHandler, @@ -24,7 +23,6 @@ from pydantic import AnyHttpUrl from starlette.requests import Request from starlette.responses import Response -from fastmcp.server.auth.redirect_validation import build_client_redirect from fastmcp.utilities.logging import get_logger from fastmcp.utilities.ui import ( INFO_BOX_STYLES, @@ -177,10 +175,8 @@ class AuthorizationHandler(SDKAuthorizationHandler): def __init__( self, - *, provider: OAuthAuthorizationServerProvider, base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, server_name: str | None = None, server_icon_url: str | None = None, ): @@ -189,17 +185,10 @@ class AuthorizationHandler(SDKAuthorizationHandler): Args: provider: OAuth authorization server provider base_url: Base URL of the server for constructing endpoint URLs - issuer_url: Authorization server issuer identifier. Defaults to - `base_url`, which is correct whenever the server's identity and - its endpoint locations are the same URL. server_name: Optional server name for branding server_icon_url: Optional server icon URL for branding """ super().__init__(provider) - # Unnormalized on purpose: this must match the discovery document's - # `issuer` field byte-for-byte per RFC 9207, and that field is built - # from the same unmodified issuer_url (see OAuthProxy.get_routes()). - self._issuer = str(issuer_url if issuer_url is not None else base_url) self._base_url = str(base_url).rstrip("/") self._server_name = server_name self._server_icon_url = server_icon_url @@ -220,31 +209,6 @@ class AuthorizationHandler(SDKAuthorizationHandler): # Call the SDK handler response = await super().handle(request) - if 300 <= response.status_code < 400 and "location" in response.headers: - redirect_url = response.headers["location"] - redirect_params = parse_qs(urlparse(redirect_url).query) - # RFC 9207: any client-facing authorization response — success - # (`code`) or error (`error`) — must carry `iss`. The base SDK - # handler's redirect target is normally `/consent` or the - # upstream IdP (neither carries `code`/`error`), but a provider - # can override `authorize()` to redirect straight back to the - # client (e.g. when consent/upstream is skipped entirely), so - # this must not be gated on "error" alone. - if "error" in redirect_params or "code" in redirect_params: - # `build_client_redirect` owns the "set `iss` idempotently" - # invariant: a provider's `authorize()` override (or the - # client's own registered redirect_uri) may already carry an - # `iss` — matching or not — and RFC 6749 §3.1 forbids a - # response parameter from appearing more than once. A - # mismatched existing value is already unusable to a - # spec-compliant client (it validates `iss` against the - # discovery document's `issuer`, i.e. `self._issuer`), so - # the helper corrects it to the canonical value rather than - # leaving it broken or appending a duplicate. - response.headers["location"] = build_client_redirect( - redirect_url, {}, iss=self._issuer - ) - # Check if this is a client not found error if response.status_code == 400: # Try to extract client_id from request for enhanced error diff --git a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py b/fastmcp_slim/fastmcp/server/auth/identity_assertion.py deleted file mode 100644 index 2c503cc06..000000000 --- a/fastmcp_slim/fastmcp/server/auth/identity_assertion.py +++ /dev/null @@ -1,533 +0,0 @@ -"""Server-side identity assertion (ID-JAG) support for FastMCP (SEP-990). - -.. warning:: - **Beta Feature**: Identity assertion support is currently in beta. The API - may change in future releases. Please report any issues you encounter. - -SEP-990 defines an enterprise "on-behalf-of" flow. A corporate identity provider -(Okta, Entra, etc.) issues an *ID-JAG* (Identity Assertion JWT Authorization -Grant) that asserts an employee's identity to a specific MCP authorization -server. The client presents that ID-JAG at the token endpoint using the RFC 7523 -``urn:ietf:params:oauth:grant-type:jwt-bearer`` grant (the RFC 8693 token-exchange -profile). This module validates the assertion and lets the authorization server -mint a short-lived access token carrying the asserted subject, with no refresh -token — the client re-exchanges a fresh ID-JAG instead, and revocation lives at -the IdP. - -This module provides: - -- ``IdentityAssertion``: a small pydantic config model attached to ``OAuthProxy`` - via the ``identity_assertion`` parameter. -- ``IdentityAssertionValidator``: validates an ID-JAG per RFC 7523 §3 and the - SEP-990 processing rules, reusing FastMCP's :class:`JWTVerifier` for signature, - issuer, audience, and expiry checks, and enforcing ``typ``, ``sub`` presence, - and ``jti`` replay protection on top. -""" - -from __future__ import annotations - -import asyncio -import time -from typing import TYPE_CHECKING -from urllib.parse import urlparse, urlunparse - -import httpx2 -from pydantic import BaseModel, Field, field_validator - -from fastmcp.utilities.auth import decode_jwt_header -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from fastmcp.server.auth.providers.jwt import JWTVerifier - -logger = get_logger(__name__) - -#: RFC 7523 §2.1 authorization grant used to present the ID-JAG. -JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" - -#: SEP-990 grant profile advertised in authorization server metadata. -ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag" - -#: SEP-990 §5.1: the ID-JAG's JOSE header ``typ`` MUST be this media type. -ID_JAG_TYP = "oauth-id-jag+jwt" - -#: Asymmetric JWS algorithms JWTVerifier supports for JWKS-based verification. -SUPPORTED_ASSERTION_ALGORITHMS = frozenset( - {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"} -) - - -class IdentityAssertion(BaseModel): - """Configuration for server-side identity assertion (ID-JAG) support. - - When attached to an :class:`~fastmcp.server.auth.oauth_proxy.OAuthProxy` via the - ``identity_assertion`` parameter, the proxy's token endpoint accepts the RFC 7523 - ``jwt-bearer`` grant carrying an ID-JAG issued by one of the ``trusted_issuers``, - and mints a short-lived FastMCP access token for the asserted subject. - - Example: - ```python - from fastmcp.server.auth import OAuthProxy, IdentityAssertion - - auth = OAuthProxy( - ..., - identity_assertion=IdentityAssertion( - trusted_issuers=["https://login.acme-corp.com"], - ), - ) - ``` - """ - - trusted_issuers: list[str] = Field( - ..., - description=( - "Issuer (`iss`) values the authorization server accepts on an ID-JAG. " - "Each must exactly match the assertion's `iss` claim. For each issuer, " - "the JWKS used to verify the assertion signature is discovered via OIDC " - "(`{issuer}/.well-known/openid-configuration`) unless overridden in " - "`jwks_uris`." - ), - ) - jwks_uris: dict[str, str] | None = Field( - default=None, - description=( - "Optional explicit JWKS URI per issuer, keyed by the issuer string. When " - "an issuer is absent here, its JWKS URI is discovered via OIDC. Provide " - "this for issuers that do not publish an OIDC discovery document." - ), - ) - audience: str | None = Field( - default=None, - description=( - "Expected `aud` value on the ID-JAG. When omitted, the audience is this " - "server's issuer identifier — the `issuer` published in its authorization " - "server metadata, which is `issuer_url` when set and `base_url` otherwise " - "— and that is where the ID-JAG's `aud` must point per SEP-990. Override " - "only when the IdP mints assertions bound to a different audience " - "identifier." - ), - ) - required_scopes: list[str] | None = Field( - default=None, - description="Scopes that must be present on the issued access token.", - ) - algorithm: str | None = Field( - default=None, - description=( - "JWS signing algorithm the trusted issuers use (e.g. `ES256`, " - "`PS256`). When omitted, verification defaults to `RS256`; IdPs " - "signing with another algorithm must set this explicitly. When " - "issuers use different algorithms, override per issuer with " - "`algorithms`." - ), - ) - algorithms: dict[str, str] | None = Field( - default=None, - description=( - "Optional per-issuer signing-algorithm override, keyed by the " - "issuer string (mirroring `jwks_uris`). Issuers absent here fall " - "back to `algorithm`." - ), - ) - access_token_expiry_seconds: int = Field( - default=300, - gt=0, - description=( - "Lifetime, in seconds, of the short-lived access token minted from an " - "ID-JAG. SEP-990 relies on the client re-exchanging a fresh assertion, so " - "this is intentionally short and no refresh token is issued." - ), - ) - - @field_validator("trusted_issuers") - @classmethod - def _validate_trusted_issuers(cls, v: list[str]) -> list[str]: - if not v: - raise ValueError("identity_assertion.trusted_issuers must not be empty") - for issuer in v: - if not issuer or not issuer.strip(): - raise ValueError("trusted_issuers entries must be non-empty strings") - return v - - @field_validator("algorithm") - @classmethod - def _validate_algorithm(cls, v: str | None) -> str | None: - # Trusted issuers are verified via JWKS (public keys only), so the - # algorithm must be one of the asymmetric JWS algorithms JWTVerifier - # actually supports — HS* (shared-secret) has no JWKS equivalent, and - # anything else (EdDSA, or a typo like RS999) would otherwise surface - # as a 500 on the first exchange instead of a clean config error now. - if v is not None and v not in SUPPORTED_ASSERTION_ALGORITHMS: - supported = ", ".join(sorted(SUPPORTED_ASSERTION_ALGORITHMS)) - raise ValueError( - f"Unsupported algorithm {v!r} for identity assertion: trusted " - f"issuers are verified via JWKS, so algorithm must be one of " - f"{supported}" - ) - return v - - @field_validator("algorithms") - @classmethod - def _validate_algorithms(cls, v: dict[str, str] | None) -> dict[str, str] | None: - if v is not None: - for issuer, algorithm in v.items(): - if algorithm not in SUPPORTED_ASSERTION_ALGORITHMS: - supported = ", ".join(sorted(SUPPORTED_ASSERTION_ALGORITHMS)) - raise ValueError( - f"Unsupported algorithm {algorithm!r} for issuer " - f"{issuer!r}: must be one of {supported}" - ) - return v - - -class IdentityAssertionError(Exception): - """Raised when an ID-JAG fails validation. - - The message is for server-side logging only; the token endpoint maps this to a - generic OAuth error response and does not leak the detail to the client. - """ - - -class IdentityAssertionValidator: - """Validates ID-JAG assertions for the SEP-990 jwt-bearer grant. - - Reuses :class:`JWTVerifier` for signature, issuer, audience, and expiry checks - (with JWKS fetching and caching), and layers on the SEP-990 processing rules - that the generic verifier does not cover: the ``typ`` JOSE header, a mandatory - ``sub``, and ``jti`` replay rejection. - - JTI replay protection mirrors :class:`CIMDAssertionValidator`: seen ``jti`` - values are cached until the assertion would expire anyway, with periodic - cleanup and an emergency size cap. Like CIMD, the cache is per-process, so - replay protection is not shared across horizontally-scaled workers or - replicas; see the identity-assertion docs for the deployment caveat. - """ - - #: RFC 7523 recommends short-lived assertions; reject anything longer. - MAX_ASSERTION_LIFETIME = 300 # 5 minutes - #: Clock-skew tolerance for exp/iat checks. - CLOCK_SKEW_SECONDS = 30 - - def __init__(self, config: IdentityAssertion, audience: str): - """Initialize the validator. - - Args: - config: The identity assertion configuration. - audience: The authorization server's own issuer URL; the ID-JAG's `aud` - must match this unless `config.audience` overrides it. - """ - self.config = config - # Accept the audience both with and without a trailing slash: metadata - # advertises the issuer exactly as pydantic renders issuer_url (a bare - # domain gains a trailing slash), so an IdP that sets `aud` to the - # advertised value verbatim must match, and so must one that strips it. - if config.audience: - self.audience: str | list[str] = config.audience - else: - base = audience.rstrip("/") - self.audience = [base, base + "/"] - - self._jti_cache: dict[str, float] = {} - self._jti_cache_max_size = 10000 - self._last_cleanup = time.monotonic() - self._cleanup_interval = 60 - # One JWTVerifier per issuer, created lazily once the JWKS URI is known. - self._verifiers: dict[str, JWTVerifier] = {} - # OIDC discovery hardening: discovery runs before signature verification, - # so a malformed-but-trusted-iss assertion can trigger an outbound HTTP - # call. Serialize per-issuer lookups and back off after a failure so - # concurrent or repeated garbage cannot amplify into request floods. - self._discovery_locks: dict[str, asyncio.Lock] = {} - self._discovery_failures: dict[str, float] = {} - self._discovery_failure_cooldown = 30.0 - - def _cleanup_expired_jtis(self) -> None: - now = time.time() - expired = [jti for jti, exp in self._jti_cache.items() if exp < now] - for jti in expired: - del self._jti_cache[jti] - if expired: - logger.debug("Cleaned up %d expired ID-JAG jtis from cache", len(expired)) - - def _maybe_cleanup(self) -> None: - now = time.monotonic() - if now - self._last_cleanup > self._cleanup_interval: - self._cleanup_expired_jtis() - self._last_cleanup = now - - async def _discover_jwks_uri(self, issuer: str) -> str: - """Discover an issuer's JWKS URI via OIDC discovery. - - Fetches ``{issuer}/.well-known/openid-configuration`` and returns its - ``jwks_uri``. Trusted issuers are operator-configured, so this uses a - plain fetch (consistent with how operator-configured JWKS URIs are - treated elsewhere, including localhost issuers in development). - """ - lock = self._discovery_locks.setdefault(issuer, asyncio.Lock()) - async with lock: - failed_at = self._discovery_failures.get(issuer) - if ( - failed_at is not None - and time.monotonic() - failed_at < self._discovery_failure_cooldown - ): - raise IdentityAssertionError( - f"OIDC discovery for issuer {issuer!r} recently failed; backing off" - ) - return await self._fetch_discovery(issuer) - - async def _fetch_discovery(self, issuer: str) -> str: - """Perform the actual discovery fetch; caller holds the issuer lock.""" - config_url = issuer.rstrip("/") + "/.well-known/openid-configuration" - try: - async with httpx2.AsyncClient() as client: - response = await client.get(config_url, timeout=10.0) - response.raise_for_status() - body = response.json() - except (httpx2.HTTPError, ValueError) as e: - self._discovery_failures[issuer] = time.monotonic() - raise IdentityAssertionError( - f"OIDC discovery for issuer {issuer!r} failed: {e}" - ) from e - if not isinstance(body, dict): - # Valid JSON that isn't an object (e.g. `[]` or a bare string) — - # guard before .get() so a misbehaving discovery endpoint maps to - # invalid_grant, not a 500 on every subsequent exchange. - raise IdentityAssertionError( - f"OIDC discovery document for issuer {issuer!r} is not a JSON object" - ) - - jwks_uri = body.get("jwks_uri") - if not jwks_uri or not isinstance(jwks_uri, str): - raise IdentityAssertionError( - f"OIDC discovery document for issuer {issuer!r} has no jwks_uri" - ) - return jwks_uri - - async def _get_verifier(self, issuer: str) -> JWTVerifier: - from fastmcp.server.auth.providers.jwt import JWTVerifier as _JWTVerifier - - verifier = self._verifiers.get(issuer) - if verifier is not None: - return verifier - - jwks_uri = (self.config.jwks_uris or {}).get(issuer) - if not jwks_uri: - jwks_uri = await self._discover_jwks_uri(issuer) - - algorithm = (self.config.algorithms or {}).get(issuer, self.config.algorithm) - verifier = _JWTVerifier( - jwks_uri=jwks_uri, - issuer=issuer, - audience=self.audience, - algorithm=algorithm, - ) - self._verifiers[issuer] = verifier - return verifier - - async def validate( - self, assertion: str, *, client_id: str, resource_url: str | None - ) -> dict: - """Validate an ID-JAG and return its claims. - - Args: - assertion: The compact-serialized ID-JAG JWT. - client_id: The authenticated client presenting the assertion. Must - match the assertion's signed `client_id` claim — checked before - the jti is recorded as consumed, so an assertion presented by - the wrong client is rejected without burning it for the right - one. - resource_url: This server's resource URL, if configured. Must match - the assertion's signed `resource` claim, for the same reason. - - Returns: - The verified claims (including `sub`, `iss`, and any `resource`/`scope`). - - Raises: - IdentityAssertionError: If the assertion is invalid for any reason. - """ - self._maybe_cleanup() - - # 1. typ header MUST be oauth-id-jag+jwt (SEP-990 §5.1). - try: - header = decode_jwt_header(assertion) - except (ValueError, KeyError, IndexError) as e: - raise IdentityAssertionError(f"Malformed assertion header: {e}") from e - if not isinstance(header, dict): - # A JSON-array/scalar header is valid JSON but not a JOSE header; - # guard before .get() so this maps to invalid_grant, not a 500. - raise IdentityAssertionError("Assertion JOSE header must be a JSON object") - if header.get("typ") != ID_JAG_TYP: - raise IdentityAssertionError( - f"Assertion typ must be {ID_JAG_TYP!r}, got {header.get('typ')!r}" - ) - - # 2. iss must be a trusted issuer before we fetch any keys for it. - try: - unverified_claims = _decode_unverified_claims(assertion) - except (ValueError, KeyError, IndexError) as e: - raise IdentityAssertionError(f"Malformed assertion payload: {e}") from e - if not isinstance(unverified_claims, dict): - raise IdentityAssertionError("Assertion payload is not a JSON object") - iss = unverified_claims.get("iss") - if not iss or iss not in self.config.trusted_issuers: - raise IdentityAssertionError(f"Untrusted assertion issuer: {iss!r}") - - # 3. Verify signature, iss, aud, and exp via JWTVerifier. - verifier = await self._get_verifier(iss) - access_token = await verifier.load_access_token(assertion) - if access_token is None: - raise IdentityAssertionError( - "Assertion failed signature/issuer/audience/expiry validation" - ) - claims = access_token.claims - - now = time.time() - exp = _numeric_date_claim(claims, "exp") - iat = _numeric_date_claim(claims, "iat") - nbf = _numeric_date_claim(claims, "nbf") - if exp is None: - raise IdentityAssertionError("Assertion must include exp claim") - if nbf is not None and nbf > now + self.CLOCK_SKEW_SECONDS: - raise IdentityAssertionError("Assertion is not yet valid (nbf in future)") - if iat is not None: - if iat > now + self.CLOCK_SKEW_SECONDS: - raise IdentityAssertionError("Assertion iat is in the future") - if exp - iat > self.MAX_ASSERTION_LIFETIME: - raise IdentityAssertionError( - f"Assertion lifetime too long (max {self.MAX_ASSERTION_LIFETIME}s)" - ) - elif exp > now + self.MAX_ASSERTION_LIFETIME: - raise IdentityAssertionError( - f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)" - ) - - # 4. sub is mandatory (RFC 7523 §3) — it identifies the end user. - sub = claims.get("sub") - if not sub: - raise IdentityAssertionError("Assertion must include sub claim") - - # 5. Required scopes on the issued access token derive from the assertion. - if self.config.required_scopes: - granted = set(_assertion_scopes(claims)) - missing = set(self.config.required_scopes) - granted - if missing: - raise IdentityAssertionError( - f"Assertion missing required scopes: {sorted(missing)}" - ) - - # 6. The signed client_id and resource claims bind the assertion to the - # presenting client and this server. Checked here — before jti is - # recorded as consumed below — so an assertion presented with the - # wrong binding is rejected without burning replay protection for - # whichever client/server it actually belongs to. - assertion_client_id = claims.get("client_id") - if not assertion_client_id or assertion_client_id != client_id: - raise IdentityAssertionError( - f"Assertion client_id {assertion_client_id!r} does not match " - f"authenticated client {client_id!r}" - ) - if resource_url is not None: - assertion_resource = claims.get("resource") - if not isinstance(assertion_resource, str) or not assertion_resource: - raise IdentityAssertionError("Assertion is missing resource claim") - if server_url_has_query(resource_url): - claim_matches = assertion_resource.rstrip("/") == resource_url.rstrip( - "/" - ) - else: - claim_matches = normalize_resource_url( - assertion_resource - ) == normalize_resource_url(resource_url) - if not claim_matches: - raise IdentityAssertionError( - f"Assertion resource {assertion_resource!r} does not match " - f"this server {resource_url!r}" - ) - - # 7. jti replay rejection (RFC 7523 §3). Must be a non-empty string — - # an array/object jti is unhashable and would raise TypeError on the - # cache lookup (a 500) instead of a clean invalid_grant. - jti = claims.get("jti") - if not jti or not isinstance(jti, str): - raise IdentityAssertionError("Assertion must include a string jti claim") - cached_exp = self._jti_cache.get(jti) - if cached_exp is not None and cached_exp > now: - raise IdentityAssertionError(f"Assertion replay detected: jti {jti} reused") - - # Enforce the cap BEFORE inserting so a rejected assertion never grows the - # cache. A fresh jti that would exceed capacity is rejected outright (after - # a cleanup pass to reclaim any expired entries first). - if ( - jti not in self._jti_cache - and len(self._jti_cache) >= self._jti_cache_max_size - ): - self._cleanup_expired_jtis() - if len(self._jti_cache) >= self._jti_cache_max_size: - logger.warning("ID-JAG jti cache at capacity, possible attack") - raise IdentityAssertionError("Server overloaded, please retry") - self._jti_cache[jti] = exp - - logger.debug("ID-JAG validated for subject=%s issuer=%s", sub, iss) - return claims - - -def normalize_resource_url(url: str) -> str: - """Normalize a resource URL by removing query parameters and trailing slashes. - - RFC 8707 allows clients to include query parameters in resource URLs, but - the server's configured resource URL typically doesn't include them. This - normalizes both sides for comparison by stripping query and fragment. - """ - parsed = urlparse(str(url)) - return urlunparse( - (parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "", "") - ) - - -def server_url_has_query(url: str) -> bool: - """Check if a URL has query parameters.""" - return bool(urlparse(str(url)).query) - - -def _numeric_date_claim(claims: dict, name: str) -> float | None: - """Read a NumericDate claim (RFC 7519 §2), rejecting non-numeric values. - - A validly-signed assertion could still carry a malformed `exp`/`iat`/`nbf` - (e.g. a string, from a misbehaving IdP); comparing against it directly - would raise `TypeError` outside the validation-error path. `bool` is - excluded even though it subclasses `int` in Python — `true`/`false` are - not timestamps. - """ - value = claims.get(name) - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise IdentityAssertionError(f"Assertion {name} claim must be a number") - return float(value) - - -def _assertion_scopes(claims: dict) -> list[str]: - """Extract the scopes an ID-JAG grants, from `scope` or `scp`.""" - scope = claims.get("scope") - if isinstance(scope, str): - return scope.split() - scp = claims.get("scp") - if isinstance(scp, list): - return [str(s) for s in scp] - if isinstance(scp, str): - return scp.split() - return [] - - -def _decode_unverified_claims(token: str) -> dict: - """Decode a JWT payload without verifying the signature. - - Used only to read the `iss` claim so we can select the trusted issuer's key - before performing the real, signature-verifying decode. - """ - import base64 - import json - - payload_b64 = token.split(".")[1] - payload_b64 += "=" * (-len(payload_b64) % 4) - return json.loads(base64.urlsafe_b64decode(payload_b64)) diff --git a/fastmcp_slim/fastmcp/server/auth/jwt_issuer.py b/fastmcp_slim/fastmcp/server/auth/jwt_issuer.py index 6715e4083..ae5c53715 100644 --- a/fastmcp_slim/fastmcp/server/auth/jwt_issuer.py +++ b/fastmcp_slim/fastmcp/server/auth/jwt_issuer.py @@ -109,8 +109,6 @@ class JWTIssuer: jti: str, expires_in: int = 3600, upstream_claims: dict[str, Any] | None = None, - subject: str | None = None, - extra_claims: dict[str, Any] | None = None, ) -> str: """Issue a minimal FastMCP access token. @@ -124,12 +122,6 @@ class JWTIssuer: jti: Unique token identifier (maps to upstream token) expires_in: Token lifetime in seconds upstream_claims: Optional claims from upstream IdP token to include - subject: Optional `sub` claim. Set for self-contained tokens (e.g. - minted from an ID-JAG) where the subject is carried directly in - the token rather than looked up via a JTI mapping. - extra_claims: Optional additional top-level claims to embed. Used to - mark self-contained tokens (e.g. the ID-JAG issuer/marker) so - `load_access_token` can validate them without a JTI mapping. Returns: Signed JWT token @@ -147,12 +139,6 @@ class JWTIssuer: "jti": jti, } - if subject is not None: - payload["sub"] = subject - - if extra_claims: - payload.update(extra_claims) - if upstream_claims: payload["upstream_claims"] = upstream_claims diff --git a/fastmcp_slim/fastmcp/server/auth/middleware.py b/fastmcp_slim/fastmcp/server/auth/middleware.py index 3540f9125..f0eb82460 100644 --- a/fastmcp_slim/fastmcp/server/auth/middleware.py +++ b/fastmcp_slim/fastmcp/server/auth/middleware.py @@ -11,12 +11,10 @@ authentication (no error attribute) and invalid authentication (with error). from __future__ import annotations import json -from typing import Any from mcp.server.auth.middleware.bearer_auth import ( RequireAuthMiddleware as SDKRequireAuthMiddleware, ) -from pydantic import AnyHttpUrl from starlette.types import Receive, Scope, Send from fastmcp.utilities.logging import get_logger @@ -36,18 +34,6 @@ class RequireAuthMiddleware(SDKRequireAuthMiddleware): (token validation failure). """ - def __init__( - self, - app: Any, - required_scopes: list[str], - resource_metadata_url: AnyHttpUrl | None = None, - challenge_scopes: list[str] | None = None, - ) -> None: - super().__init__(app, required_scopes, resource_metadata_url) - self.challenge_scopes = ( - required_scopes if challenge_scopes is None else challenge_scopes - ) - async def __call__( self, scope: Scope, @@ -100,7 +86,9 @@ class RequireAuthMiddleware(SDKRequireAuthMiddleware): Args: send: ASGI send callable """ - www_auth_parts = self._challenge_context() + www_auth_parts = [] + if self.resource_metadata_url: + www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') www_authenticate = ( ("Bearer " + ", ".join(www_auth_parts)) if www_auth_parts else "Bearer" @@ -122,16 +110,6 @@ class RequireAuthMiddleware(SDKRequireAuthMiddleware): "Missing auth: sent 401 without error attribute (RFC 6750 §3.1 compliant)" ) - def _challenge_context(self) -> list[str]: - """Build shared scope and resource metadata challenge parameters.""" - parts = [] - if self.challenge_scopes: - scope_value = " ".join(self.challenge_scopes) - parts.append(f'scope="{scope_value}"') - if self.resource_metadata_url: - parts.append(f'resource_metadata="{self.resource_metadata_url}"') - return parts - async def _send_auth_error( self, send: Send, status_code: int, error: str, description: str ) -> None: @@ -166,7 +144,8 @@ class RequireAuthMiddleware(SDKRequireAuthMiddleware): f'error="{error}"', f'error_description="{enhanced_description}"', ] - www_auth_parts.extend(self._challenge_context()) + if self.resource_metadata_url: + www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') www_authenticate = f"Bearer {', '.join(www_auth_parts)}" diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py index 5eda1d70d..c25194936 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py @@ -13,8 +13,9 @@ import hmac import json import secrets import time -from typing import TYPE_CHECKING -from urllib.parse import urlparse +from base64 import urlsafe_b64encode +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode, urlparse from pydantic import AnyUrl from starlette.requests import Request @@ -22,10 +23,7 @@ from starlette.responses import HTMLResponse, RedirectResponse from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient from fastmcp.server.auth.oauth_proxy.ui import create_consent_html -from fastmcp.server.auth.redirect_validation import ( - build_client_redirect, - validate_redirect_uri, -) +from fastmcp.server.auth.redirect_validation import validate_redirect_uri from fastmcp.utilities.logging import get_logger from fastmcp.utilities.ui import create_secure_html_response @@ -276,6 +274,43 @@ class ConsentMixin: return False return hmac.compare_digest(actual, expected_token) + def _build_upstream_authorize_url( + self: OAuthProxy, txn_id: str, transaction: dict[str, Any] + ) -> str: + """Construct the upstream IdP authorization URL using stored transaction data.""" + query_params: dict[str, Any] = { + "response_type": "code", + "client_id": self._upstream_client_id, + "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}", + "state": txn_id, + } + + scopes_to_use = transaction.get("scopes") or self.required_scopes or [] + if scopes_to_use: + query_params["scope"] = " ".join(scopes_to_use) + + # If PKCE forwarding was enabled, include the proxy challenge + proxy_code_verifier = transaction.get("proxy_code_verifier") + if proxy_code_verifier: + challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest() + proxy_code_challenge = ( + urlsafe_b64encode(challenge_bytes).decode().rstrip("=") + ) + query_params["code_challenge"] = proxy_code_challenge + query_params["code_challenge_method"] = "S256" + + # Forward resource indicator if present in transaction + if self._forward_resource: + if resource := transaction.get("resource"): + query_params["resource"] = resource + + # Extra configured parameters + if self._extra_authorize_params: + query_params.update(self._extra_authorize_params) + + separator = "&" if "?" in self._upstream_authorization_endpoint else "?" + return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" + async def _handle_consent( self: OAuthProxy, request: Request ) -> HTMLResponse | RedirectResponse: @@ -353,12 +388,9 @@ class ConsentMixin: "error": "access_denied", "state": txn.get("client_state") or "", } + sep = "&" if "?" in txn["client_redirect_uri"] else "?" return RedirectResponse( - url=build_client_redirect( - txn["client_redirect_uri"], - callback_params, - iss=str(self.issuer_url), - ), + url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}", status_code=302, ) else: @@ -531,8 +563,9 @@ class ConsentMixin: "error": "access_denied", "state": txn.get("client_state") or "", } - client_callback_url = build_client_redirect( - txn["client_redirect_uri"], callback_params, iss=str(self.issuer_url) + sep = "&" if "?" in txn["client_redirect_uri"] else "?" + client_callback_url = ( + f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}" ) response = RedirectResponse(url=client_callback_url, status_code=302) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py index 6d8770df4..7668d98fd 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/models.py @@ -14,8 +14,6 @@ from pydantic import AnyUrl, BaseModel, Field, ValidationError from fastmcp.server.auth.cimd import CIMDDocument from fastmcp.server.auth.redirect_validation import ( - is_loopback_host, - is_redirect_uri_allowed_for_application_type, matches_allowed_pattern, validate_redirect_uri, ) @@ -141,6 +139,10 @@ def _redirect_uri_path(uri_path: str) -> str: return uri_path or "/" +def _is_loopback_host(host: str | None) -> bool: + return host is not None and host.lower() in {"localhost", "127.0.0.1", "::1"} + + def _matches_registered_loopback_redirect_uri( redirect_uri: AnyUrl, registered_uri: AnyUrl, @@ -156,7 +158,7 @@ def _matches_registered_loopback_redirect_uri( requested_host = requested.hostname.lower() if requested.hostname else None registered_host = registered.hostname.lower() if registered.hostname else None - if not is_loopback_host(registered_host): + if not _is_loopback_host(registered_host): return False if requested_host != registered_host: return False @@ -216,22 +218,6 @@ class ProxyDCRClient(OAuthClientInformationFull): cimd_fetched_at: float | None = Field(default=None) allow_unregistered_redirect_uris: bool = Field(default=False, exclude=True) - def _enforce_application_type(self, redirect_uri: AnyUrl) -> None: - """Reject a redirect URI that violates the client's application_type. - - SEP-837: the web/native distinction is enforced at registration, but a - stored web client must not later authorize a loopback or custom-scheme - redirect URI (nor a native client an unsafe scheme), so the same rule is - applied here on the authorization path. - """ - if not is_redirect_uri_allowed_for_application_type( - redirect_uri, self.application_type - ): - raise InvalidRedirectUriError( - f"Redirect URI '{redirect_uri}' is not allowed for " - f"application_type '{self.application_type}'." - ) - def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: """Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. @@ -265,7 +251,6 @@ class ProxyDCRClient(OAuthClientInformationFull): f"Redirect URI '{resolved}' does not match allowed patterns." ) - self._enforce_application_type(resolved) return resolved raise InvalidRedirectUriError( @@ -278,8 +263,6 @@ class ProxyDCRClient(OAuthClientInformationFull): f"Redirect URI '{redirect_uri}' uses an unsafe scheme." ) - self._enforce_application_type(redirect_uri) - cimd_redirect_uris = ( self.cimd_document.redirect_uris if self.cimd_document else None ) @@ -333,5 +316,4 @@ class ProxyDCRClient(OAuthClientInformationFull): raise InvalidRedirectUriError( f"Redirect URI '{resolved}' does not match allowed patterns." ) - self._enforce_application_type(resolved) return resolved diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 760552bcc..1c471db6b 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -25,15 +25,13 @@ from base64 import urlsafe_b64encode from collections import OrderedDict from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from contextvars import ContextVar from typing import Any, Literal -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse, urlunparse import anyio import httpx2 from authlib.common.security import generate_token from cryptography.fernet import Fernet -from joserfc.errors import JoseError from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols import AsyncKeyValue from key_value.aio.stores.filetree import ( @@ -43,14 +41,11 @@ from key_value.aio.stores.filetree import ( ) from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from mcp.server.auth.handlers.metadata import MetadataHandler -from mcp.server.auth.handlers.register import RegistrationHandler -from mcp.server.auth.middleware.client_auth import ClientAuthenticator from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, AuthorizationParams, AuthorizeError, - IdentityAssertionParams, RefreshToken, RegistrationError, TokenError, @@ -60,14 +55,10 @@ from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) -from mcp.shared.auth import ( - OAuthClientInformationFull, - OAuthClientMetadata, - OAuthToken, -) -from pydantic import AnyHttpUrl, AnyUrl, SecretStr, ValidationError +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +from pydantic import AnyHttpUrl, AnyUrl, SecretStr from starlette.requests import Request -from starlette.responses import HTMLResponse, RedirectResponse, Response +from starlette.responses import HTMLResponse, RedirectResponse from starlette.routing import Route from typing_extensions import override @@ -80,14 +71,6 @@ from fastmcp.server.auth.auth import ( ) from fastmcp.server.auth.cimd import CIMDClientManager from fastmcp.server.auth.handlers.authorize import AuthorizationHandler -from fastmcp.server.auth.identity_assertion import ( - JWT_BEARER_GRANT_TYPE, - IdentityAssertion, - IdentityAssertionError, - IdentityAssertionValidator, - normalize_resource_url, - server_url_has_query, -) from fastmcp.server.auth.jwt_issuer import ( JWTIssuer, derive_jwt_key, @@ -109,11 +92,7 @@ from fastmcp.server.auth.oauth_proxy.models import ( ) from fastmcp.server.auth.oauth_proxy.ui import create_error_html from fastmcp.server.auth.oauth_proxy.upstream import AsyncOAuth2Client -from fastmcp.server.auth.redirect_validation import ( - build_client_redirect, - is_redirect_uri_allowed_for_application_type, - validate_redirect_uri, -) +from fastmcp.server.auth.redirect_validation import validate_redirect_uri from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger @@ -121,69 +100,29 @@ logger = get_logger(__name__) _REFRESH_LOCK_CACHE_SIZE = 10_000 -#: SEP-837: the client's declared `application_type`, recovered from the raw DCR -#: request body by `_ApplicationTypeRegistrationHandler` before the SDK's -#: `RegistrationHandler` runs. The SDK parses `application_type` into -#: `OAuthClientMetadata` but drops it when it builds the -#: `OAuthClientInformationFull` handed to `register_client`, so this ContextVar -#: is the only place the real value survives into the provider. It is `None` when -#: `register_client` is called directly (outside the HTTP route) or when the body -#: failed to parse, in which case the object's own `application_type` is used. -_pending_application_type: ContextVar[Literal["web", "native"] | None] = ContextVar( - "_pending_application_type", default=None -) +def _normalize_resource_url(url: str) -> str: + """Normalize a resource URL by removing query parameters and trailing slashes. -class _ApplicationTypeRegistrationHandler: - """Recover the DCR `application_type` the SDK handler drops (SEP-837). + RFC 8707 allows clients to include query parameters in resource URLs, but the + server's configured resource URL typically doesn't include them. This function + normalizes URLs for comparison by stripping query params and fragments. - The SDK's `RegistrationHandler` validates the request body into an - `OAuthClientMetadata` (which carries `application_type`) but omits the field - when constructing the `OAuthClientInformationFull` it passes to - `register_client`. This thin wrapper re-parses `application_type` from the - same request body and publishes it on a ContextVar so `register_client` can - enforce the web/native redirect rules, then delegates to the SDK handler - unchanged. Reading `request.body()` here is safe: Starlette caches the body, - so the SDK handler's own read returns the same bytes. + Args: + url: The URL to normalize + + Returns: + Normalized URL with scheme, host, and path only (no query/fragment) """ - - def __init__(self, handler: RegistrationHandler) -> None: - self._handler = handler - - async def handle(self, request: Request) -> Response: - application_type: Literal["web", "native"] | None = None - try: - metadata = OAuthClientMetadata.model_validate_json(await request.body()) - except ValidationError: - # Let the SDK handler surface the validation error verbatim. - application_type = None - else: - application_type = metadata.application_type - - token = _pending_application_type.set(application_type) - try: - return await self._handler.handle(request) - finally: - _pending_application_type.reset(token) + parsed = urlparse(str(url)) + return urlunparse( + (parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "", "") + ) -#: Marker claim identifying a FastMCP access token minted from a SEP-990 ID-JAG. -#: These tokens are self-contained (they carry the asserted subject directly) and -#: are validated without the upstream token-swap that regular proxy tokens use. -_ID_JAG_GRANT_MARKER = "id_jag" - - -def _assertion_granted_scopes(claims: dict[str, Any]) -> list[str]: - """Scopes granted by an ID-JAG, from its `scope` or `scp` claim.""" - scope = claims.get("scope") - if isinstance(scope, str): - return scope.split() - scp = claims.get("scp") - if isinstance(scp, list): - return [str(s) for s in scp] - if isinstance(scp, str): - return scp.split() - return [] +def _server_url_has_query(url: str) -> bool: + """Check if a URL has query parameters.""" + return bool(urlparse(str(url)).query) class OAuthProxy(OAuthProvider, ConsentMixin): @@ -342,8 +281,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): token_expiry_threshold_seconds: int = 0, # CIMD (Client ID Metadata Document) support enable_cimd: bool = True, - # Identity assertion (SEP-990 ID-JAG) support - identity_assertion: IdentityAssertion | None = None, ): """Initialize the OAuth proxy provider. @@ -386,7 +323,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): If None, an encrypted file store will be created in the data directory. jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as-is. - If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1,000,000 iterations). + If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1.2M iterations). If not provided, it will be derived from the upstream client secret using HKDF. require_authorization_consent: Consent screen behavior (default True). - True: always show the consent screen before redirecting to the @@ -397,10 +334,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin): redirect_uri) in the same browser. Cross-site navigations are still prompted to block AS-in-the-middle attacks. Lower UX friction, but weaker protection than True. - - "external": follow the same authorization path as False, but - suppress the warning as an operator acknowledgment that equivalent - consent and transaction-binding protections are enforced externally. - FastMCP does not provide or verify those external protections. + - "external": skip the built-in consent screen; consent is handled + externally (e.g. by the upstream IdP or a custom login page). - False: skip consent entirely. SECURITY WARNING: only set to False for local development or testing environments. consent_csp_policy: Content Security Policy for the consent page. @@ -442,11 +377,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based client IDs. When True, clients can authenticate using HTTPS URLs as client IDs, with metadata fetched from the URL. Supports private_key_jwt auth. - identity_assertion: Optional SEP-990 identity assertion (ID-JAG) configuration. - When provided, the token endpoint accepts the RFC 7523 jwt-bearer grant - carrying an ID-JAG issued by one of the configured trusted issuers, and - mints a short-lived access token (no refresh token) for the asserted - subject. When omitted, the grant is rejected as unsupported. """ default_scopes = valid_scopes or token_verifier.required_scopes @@ -690,23 +620,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) - # Identity assertion (SEP-990 ID-JAG): per RFC 7523 §3 the `aud` must - # identify this authorization server, and an authorization server is - # identified by its issuer — the same value published as `issuer` in - # the authorization server metadata, which is `issuer_url` (defaulting - # to `base_url`). - self._identity_assertion: IdentityAssertion | None = identity_assertion - self._identity_assertion_validator: IdentityAssertionValidator | None = None - if identity_assertion is not None: - self._identity_assertion_validator = IdentityAssertionValidator( - config=identity_assertion, - audience=str(self.issuer_url), - ) - # ID-JAG access tokens are self-contained (no upstream token or JTI - # mapping to delete), so revocation tracks their jtis here until the - # token would expire anyway. Per-process, like ID-JAG replay tracking. - self._revoked_id_jag_jtis: dict[str, float] = {} - # Advisory locks for transparent upstream token refresh, keyed by # upstream_token_id. Prevents concurrent async tasks from racing to # refresh the same token within a single process. Does not protect @@ -763,11 +676,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): super().set_mcp_path(mcp_path) # Create JWT issuer with correct audience based on actual MCP path - # This ensures tokens are bound to the specific resource URL. The `iss` - # claim is the authorization server's issuer identifier (`issuer_url`), - # which matches the `issuer` advertised in the metadata document. + # This ensures tokens are bound to the specific resource URL self._jwt_issuer = JWTIssuer( - issuer=str(self.issuer_url), + issuer=str(self.base_url), audience=str(self._resource_url), signing_key=self._jwt_signing_key, ) @@ -788,19 +699,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ) return self._jwt_issuer - @property - def token_endpoint_url(self) -> str: - """The token endpoint URL, as advertised in the authorization server metadata. - - A CIMD `private_key_jwt` assertion is bound to this URL as its `aud`, so - it must match the advertised `token_endpoint` byte-for-byte. The SDK's - `build_metadata` builds that URL by stripping any trailing slash from - `base_url` first, so this does too: pydantic renders a bare-authority - `base_url` with a trailing slash, which would otherwise expect an `aud` - of `https://example.com//token`. - """ - return f"{str(self.base_url).rstrip('/')}/token" - # ------------------------------------------------------------------------- # Upstream OAuth Client # ------------------------------------------------------------------------- @@ -863,40 +761,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): return code_verifier, code_challenge - def _build_upstream_authorize_url( - self, txn_id: str, transaction: dict[str, Any] - ) -> str: - """Construct the upstream IdP authorization URL using stored transaction data.""" - query_params: dict[str, Any] = { - "response_type": "code", - "client_id": self._upstream_client_id, - "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}", - "state": txn_id, - } - - scopes_to_use = transaction.get("scopes") or self.required_scopes or [] - if scopes_to_use: - query_params["scope"] = " ".join(scopes_to_use) - - proxy_code_verifier = transaction.get("proxy_code_verifier") - if proxy_code_verifier: - challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest() - proxy_code_challenge = ( - urlsafe_b64encode(challenge_bytes).decode().rstrip("=") - ) - query_params["code_challenge"] = proxy_code_challenge - query_params["code_challenge_method"] = "S256" - - if self._forward_resource: - if resource := transaction.get("resource"): - query_params["resource"] = resource - - if self._extra_authorize_params: - query_params.update(self._extra_authorize_params) - - separator = "&" if "?" in self._upstream_authorization_endpoint else "?" - return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" - # ------------------------------------------------------------------------- # Client Registration (Local Implementation) # ------------------------------------------------------------------------- @@ -951,14 +815,11 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "Client %s matched upstream client_id — synthesizing client without DCR", client_id, ) - synthesized_grant_types = ["authorization_code", "refresh_token"] - if self._identity_assertion is not None: - synthesized_grant_types.append(JWT_BEARER_GRANT_TYPE) return ProxyDCRClient( client_id=client_id, client_secret=None, redirect_uris=[AnyUrl("http://localhost")], - grant_types=synthesized_grant_types, + grant_types=["authorization_code", "refresh_token"], scope=self._default_scope_str, token_endpoint_auth_method="none", allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, @@ -980,25 +841,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Create a ProxyDCRClient with configured redirect URI validation if client_info.client_id is None: raise ValueError("client_id is required for client registration") - - # SEP-837: the SDK's RegistrationHandler drops application_type when it - # builds this object, so prefer the value the HTTP route recovered from - # the raw request body. Fall back to the object's own field for direct - # (non-HTTP) callers. Write it back so the DCR response echoes the type. - # - # The SDK splits the registration *request* model from the registered - # *client record*: `OAuthClientMetadata.application_type` defaults to - # "native", while `OAuthClientInformationFull.application_type` is - # `str | None` and defaults to None. Normalize the unset case back to - # "native" so a client that omits the field gets the RFC 7591 default - # recorded explicitly, on both the HTTP and direct-call paths. - pending_application_type = _pending_application_type.get() - if pending_application_type is not None: - client_info.application_type = pending_application_type - elif client_info.application_type is None: - client_info.application_type = "native" - application_type = client_info.application_type - if client_info.redirect_uris: for redirect_uri in client_info.redirect_uris: if not validate_redirect_uri( @@ -1009,45 +851,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "invalid_redirect_uri", f"Redirect URI '{redirect_uri}' is not allowed.", ) - # SEP-837: honor the client's declared application_type. "web" - # clients are restricted to non-loopback https redirect URIs. - if not is_redirect_uri_allowed_for_application_type( - redirect_uri, - application_type, - ): - raise RegistrationError( - "invalid_redirect_uri", - f"Redirect URI '{redirect_uri}' is not allowed for " - f"application_type '{application_type}'.", - ) - elif application_type == "web": - # Clients may omit redirect_uris and supply one at authorization, - # which falls back to the `http://localhost` placeholder below. A web - # client can never authorize against that placeholder (loopback http - # fails its own rule), so registering one would only produce a client - # that is guaranteed to fail later. Refuse it now, with a reason. - raise RegistrationError( - "invalid_redirect_uri", - "redirect_uris is required for application_type 'web'; web " - "clients must register a non-loopback https redirect URI.", - ) redirect_uris = client_info.redirect_uris or [AnyUrl("http://localhost")] - # When identity assertion is enabled, registered clients are allowed to - # present the SEP-990 jwt-bearer (ID-JAG) grant. Add it to the client's - # registered grant types so the token endpoint's grant-type check accepts - # it — clients that never register (or register without it while identity - # assertion is disabled) remain unable to use the grant. - registered_grant_types = list( - client_info.grant_types or ["authorization_code", "refresh_token"] - ) - if ( - self._identity_assertion is not None - and JWT_BEARER_GRANT_TYPE not in registered_grant_types - ): - registered_grant_types.append(JWT_BEARER_GRANT_TYPE) - # We use token_endpoint_auth_method="none" because the proxy handles # all upstream authentication. The client_secret must also be None # because the SDK requires secrets to be provided if they're set, @@ -1056,10 +862,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin): client_id=client_info.client_id, client_secret=None, redirect_uris=redirect_uris, - grant_types=registered_grant_types, + grant_types=client_info.grant_types + or ["authorization_code", "refresh_token"], scope=client_info.scope or self._default_scope_str, token_endpoint_auth_method="none", - application_type=application_type, allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, client_name=getattr(client_info, "client_name", None), ) @@ -1069,17 +875,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): value=proxy_client, ) - # The SDK's RegistrationHandler serializes this same `client_info` object - # into the DCR response after we return. Left untouched it would echo the - # SDK's default `client_secret_post` (or a requested `client_secret_basic`) - # and a generated secret — a confidential method the proxy never enforces - # and does not advertise in server metadata. Normalize it to the public - # client we actually store so the registration response, stored client, and - # advertised `token_endpoint_auth_methods_supported` all agree. - client_info.token_endpoint_auth_method = "none" - client_info.client_secret = None - client_info.client_secret_expires_at = None - # Log redirect URIs to help users discover what patterns they might need if client_info.redirect_uris: for uri in client_info.redirect_uris: @@ -1136,14 +931,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin): server_url = str(self._resource_url) client_url = str(client_resource) - if server_url_has_query(server_url): + if _server_url_has_query(server_url): # Server has query params - require exact match for security urls_match = client_url.rstrip("/") == server_url.rstrip("/") else: # Server has no query params - normalize both for comparison - urls_match = normalize_resource_url( + urls_match = _normalize_resource_url( client_url - ) == normalize_resource_url(server_url) + ) == _normalize_resource_url(server_url) if not urls_match: logger.warning( @@ -1172,17 +967,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Store transaction data for IdP callback processing if client.client_id is None: raise AuthorizeError( - error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type + error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] error_description="Client ID is required", ) - # Clients may omit `scope` entirely, in which case OAuth lets the - # authorization server apply its configured default. Resolve that default - # once, here, so the transaction records the scopes actually being - # authorized. Every later consumer — the consent screen, the issued - # authorization code, token exchange, and refresh — reads this one value - # instead of deciding for itself whether to substitute required_scopes. - effective_scopes = params.scopes or self.required_scopes or [] - transaction = OAuthTransaction( txn_id=txn_id, client_id=client.client_id, @@ -1190,7 +977,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): client_state=params.state or "", code_challenge=params.code_challenge, code_challenge_method=getattr(params, "code_challenge_method", "S256"), - scopes=effective_scopes, + scopes=params.scopes or [], created_at=time.time(), resource=getattr(params, "resource", None), proxy_code_verifier=proxy_code_verifier, @@ -1265,7 +1052,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Create authorization code object with PKCE challenge if client.client_id is None: raise AuthorizeError( - error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type + error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] error_description="Client ID is required", ) return AuthorizationCode( @@ -1497,131 +1284,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): scope=" ".join(granted_scopes), ) - # ------------------------------------------------------------------------- - # Identity Assertion Flow (SEP-990 ID-JAG) - # ------------------------------------------------------------------------- - - async def exchange_identity_assertion( - self, - client: OAuthClientInformationFull, - params: IdentityAssertionParams, - ) -> OAuthToken: - """Exchange a SEP-990 ID-JAG for a short-lived FastMCP access token. - - Validates the ID-JAG against the configured trusted issuers (signature, - `iss`, `aud`, `exp`, `typ`, `sub`, and `jti` replay), then mints a - self-contained FastMCP access token carrying the asserted subject. No - refresh token is issued — the client re-exchanges a fresh assertion. - - Raises: - TokenError: ``invalid_grant`` if the assertion is rejected, or - ``unsupported_grant_type`` if identity assertion is not configured. - """ - if ( - self._identity_assertion is None - or self._identity_assertion_validator is None - ): - raise TokenError( - "unsupported_grant_type", - "The JWT bearer grant is not supported by this authorization server", - ) - - # RFC 8707: when the request names a resource, it must be this server — - # the same invariant (and the same skip-when-unconfigured behavior) - # authorize() enforces for authorization requests. - if params.resource and self._resource_url: - server_url = str(self._resource_url) - client_url = str(params.resource) - if server_url_has_query(server_url): - # Server has query params - require exact match for security - resource_matches = client_url.rstrip("/") == server_url.rstrip("/") - else: - resource_matches = normalize_resource_url( - client_url - ) == normalize_resource_url(server_url) - if not resource_matches: - logger.warning( - "ID-JAG resource mismatch: client requested %s but server is %s", - client_url, - self._resource_url, - ) - raise TokenError( - "invalid_target", "Resource does not match this server" - ) - - # SEP-990: the assertion's signed client_id and resource claims (checked - # against the authenticated client and this server) bind the assertion - # before its jti is recorded as consumed — passed into validate() itself - # so that binding happens ahead of replay-tracking, not after. - try: - claims = await self._identity_assertion_validator.validate( - params.assertion, - client_id=client.client_id or "", - resource_url=str(self._resource_url) if self._resource_url else None, - ) - except IdentityAssertionError as e: - # Log detail server-side; return a generic error to the client so we - # do not leak which validation step failed. - logger.info("ID-JAG rejected: %s", e) - raise TokenError("invalid_grant", "Invalid identity assertion") from e - - subject = str(claims["sub"]) - - # Granted scopes are authoritative from the signed assertion (or, when the - # assertion omits them, from explicit server policy). The client-supplied - # request `scope` (`params.scopes`) is NOT covered by the signed assertion, - # so it may only NARROW the granted set — never widen it. A client cannot - # obtain a scope the assertion did not grant by asking for it at the token - # endpoint (e.g. an assertion granting `read` requesting `admin` gets nothing - # extra). - authoritative_scopes = _assertion_granted_scopes(claims) - if not authoritative_scopes: - authoritative_scopes = list(self._identity_assertion.required_scopes or []) - - # Configured mandatory scopes that the assertion actually grants must always - # ride on the issued token — the client request may only narrow the - # remaining, optional scopes. Otherwise a request like `scope=read` could - # silently drop a required `admin` scope the assertion authorized. - required = set(self._identity_assertion.required_scopes or []) - - if params.scopes: - requested = set(params.scopes) - granted_scopes = [ - s for s in authoritative_scopes if s in required or s in requested - ] - else: - granted_scopes = list(authoritative_scopes) - - expires_in = self._identity_assertion.access_token_expiry_seconds - access_jti = secrets.token_urlsafe(32) - - access_token = self.jwt_issuer.issue_access_token( - client_id=client.client_id or "", - scopes=granted_scopes, - jti=access_jti, - expires_in=expires_in, - subject=subject, - extra_claims={ - "fastmcp_grant": _ID_JAG_GRANT_MARKER, - "assertion_iss": str(claims.get("iss")), - }, - ) - - logger.debug( - "Issued ID-JAG access token for subject=%s client=%s jti=%s", - subject, - client.client_id, - access_jti[:8], - ) - - # SEP-990: no refresh token — the IdP controls session lifetime. - return OAuthToken( - access_token=access_token, - token_type="Bearer", - expires_in=expires_in, - scope=" ".join(granted_scopes), - ) - # ------------------------------------------------------------------------- # Refresh Token Flow # ------------------------------------------------------------------------- @@ -2146,24 +1808,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): jti = payload["jti"] upstream_claims = payload.get("upstream_claims") - # SEP-990: ID-JAG tokens are self-contained — the asserted subject - # is carried in the token itself, and there is no upstream token to - # swap for. Return directly from the verified claims, unless the - # token was revoked (tracked by jti until natural expiry). - if payload.get("fastmcp_grant") == _ID_JAG_GRANT_MARKER: - if jti in self._revoked_id_jag_jtis: - logger.info("Rejected revoked ID-JAG access token jti=%s", jti[:16]) - return None - scope = payload.get("scope", "") - return AccessToken( - token=token, - client_id=str(payload.get("client_id", "")), - scopes=scope.split() if scope else [], - expires_at=int(payload["exp"]) if payload.get("exp") else None, - subject=str(payload["sub"]) if payload.get("sub") else None, - claims=payload, - ) - # 2. Look up upstream token via JTI mapping jti_mapping = await self._jti_mapping_store.get(key=jti) if not jti_mapping: @@ -2320,28 +1964,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): if isinstance(token, RefreshToken): await self._refresh_token_store.delete(key=_hash_token(token.token)) - # ID-JAG access tokens are self-contained and never known upstream, so - # upstream revocation cannot invalidate them. Track the jti locally so - # load_access_token() rejects the token for its remaining lifetime. - try: - payload = self.jwt_issuer.verify_token(token.token) - except (JoseError, ValueError, KeyError): - # Not a (valid) FastMCP-issued JWT — nothing to track locally. - payload = None - if payload is not None and payload.get("fastmcp_grant") == _ID_JAG_GRANT_MARKER: - now = time.time() - self._revoked_id_jag_jtis = { - jti: exp for jti, exp in self._revoked_id_jag_jtis.items() if exp > now - } - exp = payload.get("exp") - jti = payload.get("jti") - if isinstance(jti, str) and jti: - self._revoked_id_jag_jtis[jti] = ( - float(exp) if isinstance(exp, (int, float)) else now + 3600 - ) - logger.debug("Revoked ID-JAG access token jti=%s", jti[:16]) - return - # Attempt upstream revocation if endpoint is configured if self._upstream_revocation_endpoint: try: @@ -2417,7 +2039,6 @@ class OAuthProxy(OAuthProvider, ConsentMixin): authorize_handler = AuthorizationHandler( provider=self, base_url=self.base_url, # ty: ignore[invalid-argument-type] - issuer_url=self.issuer_url, server_name=None, # Could be extended to pass server metadata server_icon_url=None, ) @@ -2429,30 +2050,22 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ) ) elif ( - (self._cimd_manager is not None or self._identity_assertion is not None) + self._cimd_manager is not None and isinstance(route, Route) and route.path == "/token" and route.methods is not None and "POST" in route.methods ): - # Replace the token endpoint so it can (a) authenticate CIMD - # private_key_jwt clients and (b) accept the SEP-990 jwt-bearer - # grant when identity assertion is enabled. - token_endpoint_url = self.token_endpoint_url - if self._cimd_manager is not None: - authenticator: ClientAuthenticator = ( - PrivateKeyJWTClientAuthenticator( - provider=self, - cimd_manager=self._cimd_manager, - token_endpoint_url=token_endpoint_url, - ) - ) - else: - authenticator = ClientAuthenticator(self) - token_handler = TokenHandler( + # Replace the token endpoint authenticator with one that supports + # private_key_jwt for CIMD clients + token_endpoint_url = f"{self.base_url}/token" + cimd_authenticator = PrivateKeyJWTClientAuthenticator( provider=self, - client_authenticator=authenticator, - identity_assertion_enabled=self._identity_assertion is not None, + cimd_manager=self._cimd_manager, + token_endpoint_url=token_endpoint_url, + ) + token_handler = TokenHandler( + provider=self, client_authenticator=cimd_authenticator ) custom_routes.append( Route( @@ -2464,36 +2077,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ) ) elif ( - isinstance(route, Route) - and route.path == "/register" - and route.methods is not None - and "POST" in route.methods - ): - # SEP-837: wrap the SDK RegistrationHandler so the client's - # declared application_type (which the SDK parses but drops before - # calling register_client) survives into the provider and its - # web/native redirect rules are enforced over HTTP. - registration_options = ( - self.client_registration_options or ClientRegistrationOptions() - ) - sdk_registration_handler = RegistrationHandler( - provider=self, - options=registration_options, - ) - registration_handler = _ApplicationTypeRegistrationHandler( - sdk_registration_handler - ) - custom_routes.append( - Route( - path="/register", - endpoint=cors_middleware( - registration_handler.handle, ["POST", "OPTIONS"] - ), - methods=["POST", "OPTIONS"], - ) - ) - elif isinstance(route, Route) and route.path.startswith( - "/.well-known/oauth-authorization-server" + self._cimd_manager is not None + and isinstance(route, Route) + and route.path.startswith("/.well-known/oauth-authorization-server") ): client_registration_options = ( self.client_registration_options or ClientRegistrationOptions() @@ -2504,33 +2090,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin): self.service_documentation_url, client_registration_options, revocation_options, - supports_identity_assertion=self._identity_assertion is not None, ) - # `build_metadata` derives both the `issuer` field and every - # endpoint URL from a single argument. Endpoints must stay on - # `base_url` (that is where the routes are actually mounted), - # while the issuer identity is `issuer_url`. RFC 8414 §3.3 - # requires `issuer` to match the URL the client used for - # discovery, which is the `issuer_url` advertised in the - # protected resource metadata. - metadata.issuer = self.issuer_url # ty: ignore[invalid-assignment] - # RFC 9207: every authorization response we issue carries an - # `iss` matching this issuer byte-for-byte, so this route must - # always be overridden to advertise support — not just when - # CIMD or identity assertion is also enabled. - metadata.authorization_response_iss_parameter_supported = True - # Every client the proxy authenticates at the token endpoint is - # public: DCR-registered and synthesized clients are stored with - # `token_endpoint_auth_method="none"`, and CIMD clients use - # `private_key_jwt`. The SDK's default advertisement of - # `client_secret_basic`/`client_secret_post` is misleading — the - # proxy never enforces a downstream client secret — so we override - # it to reflect the methods actually supported. - auth_methods = ["none"] - if self._cimd_manager is not None: - metadata.client_id_metadata_document_supported = True - auth_methods.append("private_key_jwt") - metadata.token_endpoint_auth_methods_supported = auth_methods + metadata.client_id_metadata_document_supported = True + existing = metadata.token_endpoint_auth_methods_supported or [] + metadata.token_endpoint_auth_methods_supported = [ + *existing, + "private_key_jwt", + "none", + ] handler = MetadataHandler(metadata) methods = route.methods or ["GET", "OPTIONS"] @@ -2627,12 +2194,9 @@ class OAuthProxy(OAuthProvider, ConsentMixin): } if error_description: error_params["error_description"] = error_description + separator = "&" if "?" in client_redirect_uri else "?" return RedirectResponse( - url=build_client_redirect( - client_redirect_uri, - error_params, - iss=str(self.issuer_url), - ), + url=f"{client_redirect_uri}{separator}{urlencode(error_params)}", status_code=302, ) # No trusted redirect_uri available — show local error page @@ -2798,8 +2362,10 @@ class OAuthProxy(OAuthProvider, ConsentMixin): "state": client_state, } - client_callback_url = build_client_redirect( - client_redirect_uri, callback_params, iss=str(self.issuer_url) + # Add query parameters to client redirect URI + separator = "&" if "?" in client_redirect_uri else "?" + client_callback_url = ( + f"{client_redirect_uri}{separator}{urlencode(callback_params)}" ) logger.debug(f"Forwarding to client callback for transaction {txn_id}") diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py index bed40f670..8739aa7a3 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/upstream.py @@ -43,7 +43,8 @@ class AsyncOAuth2Client: Drop-in replacement for the slice of authlib's `AsyncOAuth2Client` that `OAuthProxy` uses. Subclasses of `OAuthProxy` that override `_create_upstream_oauth_client` may return any object with the same - `fetch_token`/`refresh_token`/`client_secret`/`aclose` surface. + `fetch_token`/`refresh_token`/`client_secret`/`aclose` surface (including + an authlib client, if legacy httpx is installed in their environment). """ def __init__( diff --git a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py index 5e4086c7a..c96e7bdb8 100644 --- a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py @@ -18,7 +18,6 @@ from pydantic import AnyHttpUrl, BaseModel, model_validator from typing_extensions import Self from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.identity_assertion import IdentityAssertion from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import UpstreamTokenSet from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -226,7 +225,6 @@ class OIDCProxy(OAuthProxy): redirect_path: str | None = None, # Client configuration allowed_client_redirect_uris: list[str] | None = None, - valid_scopes: list[str] | None = None, client_storage: AsyncKeyValue | None = None, # JWT and encryption keys jwt_signing_key: str | bytes | None = None, @@ -248,8 +246,6 @@ class OIDCProxy(OAuthProxy): token_expiry_threshold_seconds: int = 0, # CIMD configuration enable_cimd: bool = True, - # Identity assertion (SEP-990 ID-JAG) support - identity_assertion: IdentityAssertion | None = None, ) -> None: """Initialize the OIDC proxy provider. @@ -285,15 +281,6 @@ class OIDCProxy(OAuthProxy): ports allowed to vary for MCP compatibility. Unsafe browser schemes are rejected. If empty list, no redirect URIs are allowed. These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. - valid_scopes: The complete set of scopes clients are allowed to request, - advertised to clients via the `/.well-known` endpoints (as - `scopes_supported`) and enforced at Dynamic Client Registration: a - client that registers requesting a scope outside this set is rejected. - This is a superset of `required_scopes`, which is only the floor - enforced during token validation. Defaults to `required_scopes` when - not provided, so permitting optional scopes beyond the required floor - means setting this explicitly. Valid whether or not a custom - `token_verifier` is supplied. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). @@ -306,9 +293,8 @@ class OIDCProxy(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to the upstream IdP. When False, authorization proceeds directly without user confirmation. - When "external", authorization follows the same direct path as False, - but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. consent_csp_policy: Content Security Policy for the consent page. If None (default), uses the built-in CSP policy with appropriate directives. @@ -342,9 +328,6 @@ class OIDCProxy(OAuthProxy): enable_cimd: Whether to enable CIMD (Client ID Metadata Document) client support. When True, clients can use their metadata document URL as client_id instead of Dynamic Client Registration. Default is True. - identity_assertion: Optional SEP-990 identity assertion (ID-JAG) configuration. - When provided, the token endpoint accepts the RFC 7523 jwt-bearer grant - carrying an ID-JAG issued by one of the configured trusted issuers. """ if not config_url: raise ValueError("Missing required config URL") @@ -425,7 +408,6 @@ class OIDCProxy(OAuthProxy): "issuer_url": issuer_url or base_url, "service_documentation_url": self.oidc_config.service_documentation, "allowed_client_redirect_uris": allowed_client_redirect_uris, - "valid_scopes": valid_scopes, "client_storage": client_storage, "jwt_signing_key": jwt_signing_key, "token_endpoint_auth_method": token_endpoint_auth_method, @@ -437,7 +419,6 @@ class OIDCProxy(OAuthProxy): "fastmcp_access_token_expiry_seconds": fastmcp_access_token_expiry_seconds, "token_expiry_threshold_seconds": token_expiry_threshold_seconds, "enable_cimd": enable_cimd, - "identity_assertion": identity_assertion, } if redirect_path: @@ -466,16 +447,14 @@ class OIDCProxy(OAuthProxy): self._verify_id_token = verify_id_token - # When verify_id_token strips scopes from the verifier, restore the - # derived scope state OAuthProxy.__init__ built from the (empty) verifier - # scopes. required_scopes is the enforcement floor; the advertised and - # registerable set is the broader valid_scopes when one was given. - if verify_id_token: - if required_scopes: - self.required_scopes = required_scopes - advertised_scopes = valid_scopes or required_scopes - if advertised_scopes: - self.update_default_scopes(advertised_scopes) + # When verify_id_token strips scopes from the verifier, restore + # them on the provider so they're still advertised to clients + # and enforced at the FastMCP token level. We also need to + # recompute derived state that OAuthProxy.__init__ already built + # from the (empty) verifier scopes. + if verify_id_token and required_scopes: + self.required_scopes = required_scopes + self.update_default_scopes(required_scopes) def _get_verification_token( self, upstream_token_set: UpstreamTokenSet diff --git a/fastmcp_slim/fastmcp/server/auth/providers/auth0.py b/fastmcp_slim/fastmcp/server/auth/providers/auth0.py index 29cc02a88..16ad68452 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/auth0.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/auth0.py @@ -1,15 +1,14 @@ -"""Auth0 OAuth providers for FastMCP. +"""Auth0 OAuth provider for FastMCP. -This module provides two Auth0 integrations: +This module provides a complete Auth0 integration that's ready to use with +just the configuration URL, client ID, client secret, audience, and base URL. -- ``Auth0Provider`` — OAuth proxy for fixed Auth0 application credentials -- ``Auth0MCPProvider`` — resource server for Auth0 Auth for MCP (DCR/CIMD) - -Example (OAuth proxy): +Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.auth0 import Auth0Provider + # Simple Auth0 OAuth protection auth = Auth0Provider( config_url="https://auth0.config.url", client_id="your-auth0-client-id", @@ -20,38 +19,17 @@ Example (OAuth proxy): mcp = FastMCP("My Protected Server", auth=auth) ``` - -Example (Auth for MCP): - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.auth0 import Auth0MCPProvider - - auth = Auth0MCPProvider( - config_url="https://your-tenant.auth0.com/.well-known/openid-configuration", - base_url="http://127.0.0.1:8000", - ) - - mcp = FastMCP("My MCP Server", auth=auth) - ``` """ -from __future__ import annotations +from typing import Literal -from typing import Any, Literal - -import httpx2 from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oidc_proxy import ( DEFAULT_OIDC_DISCOVERY_TIMEOUT_SECONDS, - OIDCConfiguration, OIDCProxy, ) -from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger @@ -104,7 +82,6 @@ class Auth0Provider(OIDCProxy): fallback_refresh_token_expiry_seconds: int | None = None, fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, - enable_cimd: bool = True, ) -> None: """Initialize Auth0 OAuth provider. @@ -135,9 +112,8 @@ class Auth0Provider(OIDCProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Auth0. When False, authorization proceeds directly without user confirmation. - When "external", authorization follows the same direct path as False, - but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued refresh token when the upstream provider omits `refresh_expires_in` @@ -150,8 +126,6 @@ class Auth0Provider(OIDCProxy): refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. token_expiry_threshold_seconds: Number of seconds before actual expiry to treat a token as expired, refreshing early to avoid races. Defaults to 0. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. """ # Parse scopes if provided as string auth0_required_scopes = ( @@ -178,7 +152,6 @@ class Auth0Provider(OIDCProxy): fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, - enable_cimd=enable_cimd, ) logger.debug( @@ -186,148 +159,3 @@ class Auth0Provider(OIDCProxy): client_id, auth0_required_scopes, ) - - -class Auth0JWTVerifier(JWTVerifier): - """JWT verifier for Auth0 MCP access tokens. - - Auth0's ``rfc9068_profile_authz`` token dialect exposes API permissions in - the ``permissions`` claim. Standard OAuth ``scope``/``scp`` claims are checked - first; ``permissions`` is included when present. - """ - - def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: - scopes = super()._extract_scopes(claims) - permissions = claims.get("permissions") - if isinstance(permissions, str): - return scopes + permissions.split() - if isinstance(permissions, list): - return scopes + [str(permission) for permission in permissions] - return scopes - - -class Auth0MCPProvider(RemoteAuthProvider): - """Auth0 resource server provider for Auth for MCP (DCR/CIMD). - - FastMCP validates access tokens issued by Auth0 while Auth0 handles OAuth, - dynamic client registration, and CIMD approval in the tenant dashboard. - - Enable the Resource Parameter Compatibility Profile in Auth0 and create an - API whose identifier matches this server's resource URL (logged at startup). - - Example: - ```python - from fastmcp.server.auth.providers.auth0 import Auth0MCPProvider - - auth = Auth0MCPProvider( - config_url="https://your-tenant.auth0.com/.well-known/openid-configuration", - base_url="http://127.0.0.1:8000", - ) - ``` - """ - - def __init__( - self, - *, - config_url: AnyHttpUrl | str, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - timeout_seconds: int | None = DEFAULT_OIDC_DISCOVERY_TIMEOUT_SECONDS, - ) -> None: - """Initialize Auth0 MCP resource server provider. - - Args: - config_url: Auth0 OIDC discovery URL - base_url: Public URL of this FastMCP server - resource_base_url: Optional public base URL for protected resource metadata - required_scopes: Scopes or permissions required on every token - scopes_supported: Scopes advertised in OAuth metadata - resource_name: Optional protected resource name - resource_documentation: Optional protected resource documentation URL - token_verifier: Optional custom verifier (skips audience auto-binding) - timeout_seconds: OIDC discovery timeout during construction - """ - oidc_config = OIDCConfiguration.get_oidc_configuration( - AnyHttpUrl(str(config_url)), - strict=None, - timeout_seconds=timeout_seconds, - ) - self.issuer = str(oidc_config.issuer).rstrip("/") - self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) - - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - self._auto_bind_audience = token_verifier is None - if token_verifier is None: - token_verifier = Auth0JWTVerifier( - jwks_uri=str(oidc_config.jwks_uri), - issuer=str(oidc_config.issuer), - algorithm="RS256", - required_scopes=parsed_scopes, - ) - - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(self.issuer)], - base_url=self.base_url, - resource_base_url=resource_base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def set_mcp_path(self, mcp_path: str | None) -> None: - """Bind the default verifier's audience to this server's resource URL.""" - super().set_mcp_path(mcp_path) - if ( - self._auto_bind_audience - and self._resource_url is not None - and isinstance(self.token_verifier, JWTVerifier) - ): - resource_url = str(self._resource_url) - self.token_verifier.audience = resource_url - logger.info( - "Auth0 tokens will be validated against aud=%s. " - "Set your Auth0 API identifier to this URL and enable the " - "Resource Parameter Compatibility Profile.", - resource_url, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Protected resource routes plus Auth0 authorization server metadata.""" - routes = super().get_routes(mcp_path) - metadata_url = f"{self.issuer}/.well-known/oauth-authorization-server" - - async def oauth_authorization_server_metadata(request): - try: - async with httpx2.AsyncClient() as client: - response = await client.get(metadata_url) - response.raise_for_status() - return JSONResponse(response.json()) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch Auth0 metadata: {e}", - }, - status_code=500, - ) - - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - return routes diff --git a/fastmcp_slim/fastmcp/server/auth/providers/aws.py b/fastmcp_slim/fastmcp/server/auth/providers/aws.py index 553c9b705..ff6add398 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/aws.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/aws.py @@ -86,7 +86,6 @@ class AWSCognitoTokenVerifier(JWTVerifier): client_id=access_token.client_id, scopes=access_token.scopes, expires_at=access_token.expires_at, - subject=access_token.subject, claims=cognito_claims, ) @@ -144,7 +143,6 @@ class AWSCognitoProvider(OIDCProxy): fallback_refresh_token_expiry_seconds: int | None = None, fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, - enable_cimd: bool = True, ): """Initialize AWS Cognito OAuth provider. @@ -175,9 +173,8 @@ class AWSCognitoProvider(OIDCProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to AWS Cognito. When False, authorization proceeds directly without user confirmation. - When "external", authorization follows the same direct path as False, - but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. fallback_refresh_token_expiry_seconds: Lifetime for the FastMCP-issued refresh token when the upstream provider omits `refresh_expires_in` @@ -190,8 +187,6 @@ class AWSCognitoProvider(OIDCProxy): refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. token_expiry_threshold_seconds: Number of seconds before actual expiry to treat a token as expired, refreshing early to avoid races. Defaults to 0. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. """ # Parse scopes if provided as string required_scopes_final = ( @@ -227,7 +222,6 @@ class AWSCognitoProvider(OIDCProxy): fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, - enable_cimd=enable_cimd, ) logger.debug( diff --git a/fastmcp_slim/fastmcp/server/auth/providers/azure.py b/fastmcp_slim/fastmcp/server/auth/providers/azure.py index b0cf9d360..3914573d2 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/azure.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/azure.py @@ -173,9 +173,8 @@ class AzureProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Azure. When False, authorization proceeds directly without user confirmation. - When "external", authorization follows the same direct path as False, - but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx2.AsyncClient for connection pooling in JWKS fetches. When provided, the client is reused for JWT key fetches and the caller @@ -783,19 +782,10 @@ class AzureJWTVerifier(JWTVerifier): property returns the full-URI form for OAuth metadata while ``required_scopes`` retains the short form for token validation. """ - return self.get_challenge_scopes() - - def get_challenge_scopes( - self, required_scopes: list[str] | None = None - ) -> list[str]: - """Prefix any effective validation scopes for Azure authorization.""" - effective_scopes = ( - self.required_scopes if required_scopes is None else required_scopes - ) - if not effective_scopes: + if not self.required_scopes: return [] prefixed = [] - for scope in effective_scopes: + for scope in self.required_scopes: if scope in OIDC_SCOPES or "://" in scope or "/" in scope: prefixed.append(scope) else: diff --git a/fastmcp_slim/fastmcp/server/auth/providers/clerk.py b/fastmcp_slim/fastmcp/server/auth/providers/clerk.py index cb7a20660..b6989d370 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/clerk.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/clerk.py @@ -212,7 +212,6 @@ class ClerkTokenVerifier(TokenVerifier): client_id=aud or sub, scopes=token_scopes, expires_at=expires_at, - subject=sub, claims={ "sub": sub, "aud": aud, @@ -327,9 +326,8 @@ class ClerkProvider(OAuthProxy): into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. require_authorization_consent: Whether to require user consent before authorizing - clients (default True). When "external", authorization follows the same direct - path as False, but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + clients (default True). When "external", the built-in consent screen is skipped + but no warning is logged, indicating that consent is handled externally by Clerk. consent_csp_policy: Custom CSP policy for the consent page. extra_authorize_params: Additional parameters to forward to Clerk's authorization endpoint. Example: {"prompt": "login"} to force re-authentication. diff --git a/fastmcp_slim/fastmcp/server/auth/providers/discord.py b/fastmcp_slim/fastmcp/server/auth/providers/discord.py index 8a6b657b6..dec5e707f 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/discord.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/discord.py @@ -139,7 +139,6 @@ class DiscordTokenVerifier(TokenVerifier): client_id=client_id, scopes=token_scopes, expires_at=expires_at, - subject=user_data.get("id"), claims={ "sub": user_data.get("id"), "username": user_data.get("username"), @@ -241,9 +240,8 @@ class DiscordProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Discord. When False, authorization proceeds directly without user confirmation. - When "external", authorization follows the same direct path as False, - but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx2.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller diff --git a/fastmcp_slim/fastmcp/server/auth/providers/github.py b/fastmcp_slim/fastmcp/server/auth/providers/github.py index 214d24c3b..b5f744297 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/github.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/github.py @@ -154,7 +154,6 @@ class GitHubTokenVerifier(TokenVerifier): client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID scopes=token_scopes, expires_at=None, # GitHub tokens don't typically expire - subject=str(user_data["id"]), claims={ "sub": str(user_data["id"]), "login": user_data.get("login"), @@ -257,9 +256,8 @@ class GitHubProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to GitHub. When False, authorization proceeds directly without user confirmation. - When "external", authorization follows the same direct path as False, - but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx2.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller diff --git a/fastmcp_slim/fastmcp/server/auth/providers/google.py b/fastmcp_slim/fastmcp/server/auth/providers/google.py index a8536b223..28d9a5beb 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/google.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/google.py @@ -176,7 +176,6 @@ class GoogleTokenVerifier(TokenVerifier): client_id=sub, scopes=token_scopes, expires_at=expires_at, - subject=sub, claims={ "sub": sub, "aud": aud, @@ -290,9 +289,8 @@ class GoogleProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Google. When False, authorization proceeds directly without user confirmation. - When "external", authorization follows the same direct path as False, - but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by Google's own consent). SECURITY WARNING: Only set to False for local development or testing environments. extra_authorize_params: Additional parameters to forward to Google's authorization endpoint. By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure diff --git a/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py b/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py index 22b6c5d6b..07cfaf4f3 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/huggingface.py @@ -134,7 +134,6 @@ class HuggingFaceTokenVerifier(TokenVerifier): client_id=str(sub), scopes=token_scopes, expires_at=None, - subject=str(sub), claims={ "sub": str(sub), "name": userinfo.get("name"), @@ -209,9 +208,6 @@ class HuggingFaceProvider(OAuthProxy): client_secret: Hugging Face OAuth app client secret. Optional for public PKCE apps; when omitted, ``jwt_signing_key`` is required. base_url: Public URL where OAuth endpoints will be accessible. - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use - root-level URL to avoid 404s during discovery when mounting under - a path. required_scopes: Required Hugging Face scopes. Defaults to ``["openid", "profile"]``. valid_scopes: Scopes clients may request. Defaults to required scopes. diff --git a/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py b/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py index 67068bd58..3ae686dac 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/in_memory.py @@ -36,10 +36,8 @@ class InMemoryOAuthProvider(OAuthProvider): def __init__( self, - *, base_url: AnyHttpUrl | str | None = None, resource_base_url: AnyHttpUrl | str | None = None, - issuer_url: AnyHttpUrl | str | None = None, service_documentation_url: AnyHttpUrl | str | None = None, client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, @@ -48,7 +46,6 @@ class InMemoryOAuthProvider(OAuthProvider): super().__init__( base_url=base_url or "http://fastmcp.example.com", resource_base_url=resource_base_url, - issuer_url=issuer_url, service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, revocation_options=revocation_options, diff --git a/fastmcp_slim/fastmcp/server/auth/providers/introspection.py b/fastmcp_slim/fastmcp/server/auth/providers/introspection.py index e11d24db6..6d071d265 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/introspection.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/introspection.py @@ -287,7 +287,6 @@ class IntrospectionTokenVerifier(TokenVerifier): client_id=str(client_id), scopes=scopes, expires_at=int(exp) if exp is not None else None, - subject=introspection_data.get("sub"), claims=introspection_data, # Store full response for extensibility ) self._cache.set(token, result) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/jwt.py b/fastmcp_slim/fastmcp/server/auth/providers/jwt.py index d991b2ab6..aedbee26f 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/jwt.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/jwt.py @@ -6,7 +6,7 @@ import contextlib import json import time from dataclasses import dataclass -from typing import Any, Literal, TypeAlias, cast +from typing import Any, TypeAlias, cast import httpx2 from cryptography.hazmat.primitives import serialization @@ -29,20 +29,14 @@ JWKKeyData: TypeAlias = dict[str, str | list[str]] SUPPORTED_JWS_HEADER_FIELDS = frozenset(JWS_HEADER_REGISTRY) -def _key_type_for_algorithm(algorithm: str) -> Literal["oct", "RSA", "EC", "OKP"]: - if algorithm.startswith("HS"): - return "oct" - if algorithm.startswith(("RS", "PS")): - return "RSA" - if algorithm.startswith("ES"): - return "EC" - if algorithm in {"EdDSA", "Ed25519", "Ed448"}: - return "OKP" - raise ValueError(f"Unsupported algorithm: {algorithm}.") - - def _import_key_for_algorithm(key: str | bytes | JWKKeyData, algorithm: str): - return jwk.import_key(key, _key_type_for_algorithm(algorithm)) + if algorithm.startswith("HS"): + return jwk.import_key(key, "oct") + if algorithm.startswith(("RS", "PS")): + return jwk.import_key(key, "RSA") + if algorithm.startswith("ES"): + return jwk.import_key(key, "EC") + raise ValueError(f"Unsupported algorithm: {algorithm}.") def _jwk_to_pem(key_data: JWKKeyData) -> str: @@ -51,8 +45,6 @@ def _jwk_to_pem(key_data: JWKKeyData) -> str: return jwk.import_key(key_data, "RSA").as_pem().decode("utf-8") if key_type == "EC": return jwk.import_key(key_data, "EC").as_pem().decode("utf-8") - if key_type == "OKP": - return jwk.import_key(key_data, "OKP").as_pem().decode("utf-8") raise ValueError(f"Unsupported JWK key type: {key_type!r}") @@ -80,8 +72,6 @@ class JWKData(TypedDict, total=False): alg: str # Algorithm (e.g., "RS256") n: str # Modulus (for RSA keys) e: str # Exponent (for RSA keys) - crv: str # Curve name (for EC and OKP keys) - x: str # Public key coordinate (for EC and OKP keys) x5c: list[str] # X.509 certificate chain (for JWKs) x5t: str # X.509 certificate thumbprint (for JWKs) @@ -204,11 +194,10 @@ def _looks_like_pem_public_key(key: str | bytes) -> bool: class JWTVerifier(TokenVerifier): """ - JWT token verifier supporting asymmetric (RSA/ECDSA/EdDSA) and symmetric (HMAC) algorithms. + JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. This verifier validates JWT tokens using various signing algorithms: - - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512, - Ed25519, Ed448, and legacy EdDSA): + - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512): Uses public/private key pairs. Ideal for external clients and services where only the authorization server has the private key. - **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both @@ -243,7 +232,7 @@ class JWTVerifier(TokenVerifier): jwks_uri: URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS. issuer: Expected issuer claim value or list of allowed issuer values. audience: Expected audience claim value or list of allowed audience values. - algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512, Ed25519, Ed448, and legacy EdDSA. + algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512. required_scopes: Scopes that must be present in validated tokens. base_url: Base URL passed to the parent TokenVerifier. ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only, @@ -286,9 +275,6 @@ class JWTVerifier(TokenVerifier): "PS256", "PS384", "PS512", - "EdDSA", - "Ed25519", - "Ed448", }: raise ValueError(f"Unsupported algorithm: {algorithm}.") @@ -361,31 +347,19 @@ class JWTVerifier(TokenVerifier): try: jwks_data = await self._fetch_jwks() - # Cache all usable keys. A key that cannot be converted is skipped - # rather than failing the whole set — per RFC 7517 §5, clients - # should ignore JWKs they don't understand. Otherwise one exotic - # key published by the authorization server would reject every - # token, including ones signed by supported keys in the same set - # (#4515). + # Cache all usable keys. A key that cannot be converted (e.g. an + # unsupported kty like OKP/Ed25519) is skipped rather than failing + # the whole set — per RFC 7517 §5, clients should ignore JWKs they + # don't understand. Otherwise one exotic key published by the + # authorization server would reject every token, including ones + # signed by supported keys in the same set (#4515). self._jwks_cache = {} skipped_kids: set[str] = set() - expected_key_type = _key_type_for_algorithm(self.algorithm) for key_data in jwks_data.get("keys", []): if not isinstance(key_data, dict): self.logger.debug("Skipping non-object JWKS entry: %r", key_data) continue key_kid = key_data.get("kid") - if key_data.get("kty") != expected_key_type: - self.logger.debug( - "Skipping JWKS key %r: key type %r is incompatible " - "with algorithm %s", - key_kid, - key_data.get("kty"), - self.algorithm, - ) - if key_kid: - skipped_kids.add(key_kid) - continue try: public_key = _jwk_to_pem(key_data) except (JoseError, TypeError, KeyError, ValueError) as e: @@ -614,7 +588,6 @@ class JWTVerifier(TokenVerifier): client_id=str(client_id), scopes=scopes, expires_at=int(exp) if exp is not None else None, - subject=claims.get("sub"), claims=claims, ) @@ -703,6 +676,5 @@ class StaticTokenVerifier(TokenVerifier): client_id=token_data["client_id"], scopes=scopes, expires_at=expires_at, - subject=token_data.get("sub"), claims=token_data, ) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/oci.py b/fastmcp_slim/fastmcp/server/auth/providers/oci.py index 613efd69b..f5fb8bea7 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/oci.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/oci.py @@ -141,7 +141,6 @@ class OCIProvider(OIDCProxy): fallback_refresh_token_expiry_seconds: int | None = None, fastmcp_access_token_expiry_seconds: int | None = None, token_expiry_threshold_seconds: int = 0, - enable_cimd: bool = True, ) -> None: """Initialize OCI OIDC provider. @@ -157,10 +156,7 @@ class OCIProvider(OIDCProxy): resource_base_url: Optional public base URL for the protected resource metadata and token audience. Defaults to ``base_url``. audience: OCI API audience (optional) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). This is - this server's own OAuth identity, not the OCI IAM Domain's — it has no - effect on the upstream issuer taken from the discovery URL. Use a - root-level URL to avoid 404s during discovery when mounting under a path. + issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL. required_scopes: Required OCI scopes (defaults to ["openid"]) redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback". allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. @@ -175,8 +171,6 @@ class OCIProvider(OIDCProxy): refresh gracefully (e.g. `mcp-remote`). See `OAuthProxy` for details. token_expiry_threshold_seconds: Number of seconds before actual expiry to treat a token as expired, refreshing early to avoid races. Defaults to 0. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. """ # Parse scopes if provided as string oci_required_scopes = ( @@ -203,7 +197,6 @@ class OCIProvider(OIDCProxy): fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, fastmcp_access_token_expiry_seconds=fastmcp_access_token_expiry_seconds, token_expiry_threshold_seconds=token_expiry_threshold_seconds, - enable_cimd=enable_cimd, ) logger.debug( diff --git a/fastmcp_slim/fastmcp/server/auth/providers/workos.py b/fastmcp_slim/fastmcp/server/auth/providers/workos.py index 18c0a763a..7ca3b924a 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/workos.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/workos.py @@ -105,7 +105,6 @@ class WorkOSTokenVerifier(TokenVerifier): client_id=str(user_data.get("sub", "unknown")), scopes=token_scopes, expires_at=None, # Will be set from token introspection if needed - subject=user_data.get("sub"), claims={ "sub": user_data.get("sub"), "email": user_data.get("email"), @@ -213,9 +212,8 @@ class WorkOSProvider(OAuthProxy): require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to WorkOS. When False, authorization proceeds directly without user confirmation. - When "external", authorization follows the same direct path as False, - but the warning is suppressed as an operator acknowledgment that - equivalent protections are enforced externally. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. extra_authorize_params: Additional parameters to forward to WorkOS's authorization endpoint. Useful for forcing scopes like `offline_access` so WorkOS issues a refresh token, diff --git a/fastmcp_slim/fastmcp/server/auth/redirect_validation.py b/fastmcp_slim/fastmcp/server/auth/redirect_validation.py index 9bb3c1e69..cdc62cc4f 100644 --- a/fastmcp_slim/fastmcp/server/auth/redirect_validation.py +++ b/fastmcp_slim/fastmcp/server/auth/redirect_validation.py @@ -5,8 +5,7 @@ protecting against userinfo-based bypass attacks like http://localhost@evil.com. """ import fnmatch -import ipaddress -from urllib.parse import unquote, urlencode, urlparse, urlunparse +from urllib.parse import unquote, urlparse from pydantic import AnyUrl @@ -20,99 +19,6 @@ UNSAFE_REDIRECT_URI_SCHEMES = frozenset( ) -def add_query_params(url: str, params: dict[str, str]) -> str: - """Append query parameters to a URL while preserving existing parameters. - - The existing query string is appended to verbatim rather than decoded - and re-serialized, since registered redirect URIs may carry opaque or - signed query strings whose exact bytes matter to the receiving client - (for example, a valueless `?flag` must not become `?flag=`, and - non-UTF-8 percent-encoded sequences must not be replaced). - """ - parsed = urlparse(url) - new_query = urlencode(params) - query = f"{parsed.query}&{new_query}" if parsed.query else new_query - return urlunparse(parsed._replace(query=query)) - - -def replace_query_param(url: str, key: str, value: str) -> str: - """Replace the first occurrence of `key` in a URL's query string in place. - - Like `add_query_params`, this does not round-trip the query through - `parse_qsl`/`urlencode`: every segment other than the matched one is - passed through byte-for-byte, so opaque or non-UTF-8 percent-encoded - values elsewhere in the query are left untouched. Only the matched - segment's encoding is replaced (with `key=value`, freshly - `urlencode`d). If `key` is not present, it is appended, matching - `add_query_params`'s behavior. - """ - parsed = urlparse(url) - segments = parsed.query.split("&") if parsed.query else [] - new_segment = urlencode({key: value}) - - replaced = False - new_segments: list[str] = [] - for segment in segments: - segment_key = segment.split("=", 1)[0] - if not replaced and unquote(segment_key) == key: - new_segments.append(new_segment) - replaced = True - else: - new_segments.append(segment) - if not replaced: - new_segments.append(new_segment) - - return urlunparse(parsed._replace(query="&".join(new_segments))) - - -def build_client_redirect(url: str, params: dict[str, str], *, iss: str) -> str: - """Build a client-facing authorization redirect that carries exactly one `iss`. - - Every redirect this server sends back to an OAuth client from the - authorization endpoint -- success (carrying `code`) or error (carrying - `error`) -- must carry the proxy's RFC 9207 issuer exactly once (RFC - 6749 §3.1 forbids a response parameter from appearing more than once). - A registered redirect_uri can legitimately carry its own `iss` query - parameter already (e.g. `https://client.example/callback?iss=tenant`), - so blindly appending the server's issuer on top of that would duplicate - it -- this is what every client-facing redirect site must get right, - and the reason this helper exists instead of five call sites each - reimplementing the same invariant by hand. - - `params` is appended to `url` via `add_query_params` (verbatim, without - re-encoding the existing query -- see that function's docstring), and - `iss` is then set idempotently via `replace_query_param`: an existing - occurrence -- whether contributed by the registered redirect_uri or - already present in `url` -- is overwritten with the canonical value; - otherwise `iss` is appended. - - `iss` is keyword-only and required so a caller cannot forget to pass - it. `params` must not itself contain an `"iss"` key -- pass it via the - `iss` keyword instead, so there is exactly one place the value can come - from. - - Args: - url: The redirect target -- normally the client's registered - redirect_uri. - params: The response parameters to append (e.g. `code`/`state`, or - `error`/`error_description`). Must not include `"iss"`. - iss: The canonical RFC 9207 issuer, byte-for-byte equal to the - discovery document's `issuer` (`str(self.base_url)` / - `self._issuer` -- never the rstripped `self._base_url`). - - Returns: - `url` with `params` appended and exactly one `iss` query parameter - set to `iss`. - """ - if "iss" in params: - raise ValueError( - "params must not include 'iss' -- pass it via the 'iss' keyword" - ) - if params: - url = add_query_params(url, params) - return replace_query_param(url, "iss", iss) - - def _parse_host_port(netloc: str) -> tuple[str | None, str | None]: """Parse host and port from netloc, handling wildcards. @@ -171,58 +77,15 @@ def _match_host(uri_host: str | None, pattern_host: str | None) -> bool: return uri_host == pattern_host -def is_loopback_host(host: str | None) -> bool: +def _is_loopback_host(host: str | None) -> bool: """Check if a host is a loopback address. - Per RFC 8252 §7.3, loopback covers the whole reserved loopback range, not - just the two familiar literals: IPv4 `127.0.0.0/8` (so `127.0.0.2` and - `127.5.5.5` are loopback just as much as `127.0.0.1`) and IPv6 `::1`. IP - hosts are therefore classified with `ipaddress.ip_address().is_loopback` - rather than string equality — checking only `127.0.0.1` would let a web - client register `https://127.0.0.2/callback` and slip past the - non-loopback requirement. - - Names are handled per RFC 6761 §6.3, which reserves the entire `localhost` - namespace for the local machine: the exact name `localhost` *and* any - subdomain of it (`app.localhost`, `api.app.localhost`). `.localhost` is a - reserved TLD that cannot be registered, so a subdomain of it always resolves - to the loopback interface and must count as loopback in both directions — - otherwise a web client could register `https://app.localhost/callback` and - slip past the non-loopback requirement, while a native client using - `http://app.localhost:3000/callback` would be wrongly rejected. - - The suffix test is anchored on a leading dot so it cannot be spoofed by a - registrable domain: `localhost.evil.com` and `notlocalhost` are ordinary - public names and are *not* loopback. - - Hosts are also normalized before classification: bracketed IPv6 literals - (`[::1]`) are unwrapped, and a single trailing dot (the absolute/FQDN form, - e.g. `localhost.` or `127.0.0.1.`) is stripped, since it denotes the same - host. Non-IP hosts fall through to the name check without raising. + Per RFC 8252 §7.3, loopback addresses include localhost, 127.0.0.1, and ::1. """ if not host: return False - host = host.lower() - - # urlparse().hostname strips brackets, but callers that parse the netloc - # themselves may still pass a bracketed IPv6 literal. - if host.startswith("[") and host.endswith("]"): - host = host[1:-1] - - # Absolute (fully qualified) form: `localhost.` and `127.0.0.1.` name the - # same hosts as their relative spellings. - if host.endswith("."): - host = host[:-1] - - if not host: - return False - - try: - return ipaddress.ip_address(host).is_loopback - except ValueError: - # Not an IP literal — RFC 6761 §6.3 reserved localhost namespace. - return host == "localhost" or host.endswith(".localhost") + return host in ("localhost", "127.0.0.1", "::1") def _match_port( @@ -358,7 +221,7 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool: return False # RFC 8252 §7.3: loopback patterns without an explicit port match any port - if not (is_loopback_host(pattern_host) and pattern_port is None): + if not (_is_loopback_host(pattern_host) and pattern_port is None): if not _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()): return False @@ -366,64 +229,6 @@ def matches_allowed_pattern(uri: str, pattern: str) -> bool: return _match_path(uri_parsed.path, pattern_parsed.path) -def is_redirect_uri_allowed_for_application_type( - redirect_uri: str | AnyUrl, - application_type: str | None, -) -> bool: - """Check a redirect URI against RFC 7591 / SEP-837 `application_type` rules. - - `application_type` governs which redirect URIs a Dynamically Registered - Client may use (RFC 7591 §2, OpenID Connect Dynamic Client Registration §2): - - - `"web"` clients must use `https` redirect URIs on a non-loopback host. - Loopback `http`, `https://localhost`, and app/custom schemes are rejected. - This is the restriction SEP-837 actually asks for. - - `"native"` clients keep every scheme FastMCP already allowed, except that - `http` is restricted to loopback hosts (RFC 8252 §7.3, any port). App and - private-use schemes pass through untouched: `vscode://`, - `com.example.app:/callback`, `myapp://callback`, `urn:ietf:wg:oauth:2.0:oob`. - - Deliberately absent: any attempt to classify a native client's scheme as - "private-use" versus "a network transport". There is no sound test. The IANA - registry cannot separate them — `vscode` is registered *because* it is an - app-dispatch scheme, alongside transports like `coap` and `smb` — and - reverse-domain notation fails too, since `iris.beep` and - `microsoft.windows.camera` are registered while `myapp` is not. Every - formulation either rejects schemes real MCP clients depend on or admits the - ones it meant to exclude, so native scheme filtering is left to the - unsafe-scheme check below. - - Unsafe browser schemes (`javascript:`, `data:`, `file:`, `vbscript:`) are - always rejected regardless of `application_type`. That check predates this - function and is unchanged by it. - - The MCP SDK defaults `application_type` to `"native"` because MCP clients - typically register loopback redirect URIs, so omitting the field preserves - the behavior clients relied on before this check existed. `None` — which a - registered-client record carries when the field was never set — is treated - the same way. - """ - uri_str = str(redirect_uri) - - if _is_unsafe_redirect_uri(uri_str): - return False - - parsed = urlparse(uri_str) - scheme = parsed.scheme.lower() - - if application_type == "web": - # "web": require an https redirect URI on a non-loopback host. - if scheme != "https": - return False - return not is_loopback_host(parsed.hostname) - - # "native" (and the SDK default): cleartext http only to a loopback host; - # every other non-unsafe scheme is left alone. - if scheme == "http": - return is_loopback_host(parsed.hostname) - return True - - def validate_redirect_uri( redirect_uri: str | AnyUrl | None, allowed_patterns: list[str] | None, diff --git a/fastmcp_slim/fastmcp/server/auth/ssrf.py b/fastmcp_slim/fastmcp/server/auth/ssrf.py index 4519e74f9..49de0d33a 100644 --- a/fastmcp_slim/fastmcp/server/auth/ssrf.py +++ b/fastmcp_slim/fastmcp/server/auth/ssrf.py @@ -4,22 +4,12 @@ This module provides SSRF-protected HTTP fetching with: - DNS resolution and IP validation before requests - DNS pinning to prevent rebinding TOCTOU attacks - Support for both CIMD and JWKS fetches - -When ``FASTMCP_SSRF_TRUST_PROXY`` is set, DNS resolution and the IP blocklist are -skipped and a single request is made to the hostname URL through the configured -HTTPS_PROXY/ALL_PROXY, delegating DNS and egress to that trusted proxy (the scheme -and hostname checks still apply). The proxy URL is read from the environment and -passed to httpx2 explicitly with ``trust_env`` disabled, so the request is provably -routed through the proxy rather than predicted to be — NO_PROXY is not evaluated in -this mode. If no proxy is configured, the fetch is refused rather than sent direct -with the blocklist disabled. """ from __future__ import annotations import asyncio import ipaddress -import os import socket import time from collections.abc import Mapping @@ -28,7 +18,6 @@ from urllib.parse import urlparse import httpx2 -import fastmcp from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -73,26 +62,6 @@ def format_ip_for_url(ip_str: str) -> str: return ip_str -def _configured_proxy_url() -> str | None: - """Return the proxy URL to route proxy-trust fetches through, if any is set. - - Reads ``HTTPS_PROXY``/``https_proxy`` first, falling back to ``ALL_PROXY``/ - ``all_proxy``. This is a simple presence check: no host matching, no ``NO_PROXY`` - evaluation. The caller passes the returned URL to httpx2 explicitly (with - ``trust_env`` disabled) so there is no routing decision left for httpx2 to make - differently than this function assumed — see the module docstring and - :func:`validate_url` for why that matters. - - Returns: - The configured proxy URL, or None if none of the supported variables are set. - """ - for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"): - value = os.environ.get(name) - if value: - return value - return None - - class SSRFError(Exception): """Raised when an SSRF protection check fails.""" @@ -210,7 +179,6 @@ class ValidatedURL: port: int path: str resolved_ips: list[str] - proxy_url: str | None = None @dataclass @@ -222,55 +190,6 @@ class SSRFFetchResponse: headers: dict[str, str] -@dataclass -class _FetchTarget: - """A single connection attempt for an SSRF-safe fetch. - - In pinned (default) mode there is one target per resolved IP: the request goes to - an IP-literal URL with Host and SNI pinned to the validated hostname. In proxy - mode (FASTMCP_SSRF_TRUST_PROXY) there is a single target: the original hostname - URL with no pinning and an explicit ``proxy_url``, so the request is dialed - through the trusted proxy and the proxy (not httpx's environment-proxy routing) - owns DNS and TLS. - """ - - url: str - host_header: str | None - sni_hostname: str | None - proxy_url: str | None = None - - -def _build_fetch_targets(validated: ValidatedURL) -> list[_FetchTarget]: - """Build the ordered connection attempts for a validated URL. - - An empty ``resolved_ips`` means proxy mode (see :func:`validate_url`): a single - unpinned request to the original hostname URL, explicitly routed through - ``validated.proxy_url``. Otherwise, one pinned IP-literal request per resolved - IP, tried in order with fallback on connection error. - """ - if not validated.resolved_ips: - # Proxy mode: dial the original hostname URL verbatim and let the proxy parse - # and resolve it. validated.hostname is informational here — it does not - # constrain what gets dialed — so do not pin Host or SNI from it. - return [ - _FetchTarget( - url=validated.original_url, - host_header=None, - sni_hostname=None, - proxy_url=validated.proxy_url, - ) - ] - - return [ - _FetchTarget( - url=f"https://{format_ip_for_url(ip)}:{validated.port}{validated.path}", - host_header=validated.hostname, - sni_hostname=validated.hostname, - ) - for ip in validated.resolved_ips - ] - - async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: """Validate URL for SSRF and resolve to IPs. @@ -282,8 +201,7 @@ async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: ValidatedURL with resolved IPs Raises: - SSRFError: If the URL is invalid, resolves to blocked IPs, or proxy-trust - mode is enabled but no configured proxy will route the request. + SSRFError: If URL is invalid or resolves to blocked IPs """ try: parsed = urlparse(url) @@ -301,54 +219,8 @@ async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: hostname = parsed.hostname or parsed.netloc port = parsed.port or 443 - path = parsed.path + ("?" + parsed.query if parsed.query else "") - # Proxy mode (FASTMCP_SSRF_TRUST_PROXY): a trusted outbound proxy owns DNS and - # egress, so resolving the hostname here is pointless — the IP we'd pin is not - # the one the proxy dials, making the blocklist unenforceable theater. Skip - # resolution and the blocklist entirely and signal proxy mode downstream with an - # empty resolved_ips list. The scheme (HTTPS) and host checks above still run. - if fastmcp.settings.ssrf_trust_proxy: - # Skipping the blocklist is only safe if the request is *actually* routed - # through a trusted proxy, so this does not try to predict whether it will - # be — it controls it. Earlier revisions predicted httpx2's routing decision, - # first by approximating NO_PROXY handling with urllib.request.proxy_bypass(), - # then by replicating httpx2's own get_environment_proxies()/URLPattern - # matching internally. Both were still predictions of a library with - # open-ended NO_PROXY semantics, and each was found wrong for a different - # NO_PROXY form (port-qualified, IPv6, scheme-qualified entries each broke a - # different revision) — always in the dangerous direction of assuming - # "proxied" for a request that actually went out direct. - # - # Instead, read the proxy URL directly from the environment and hand it to - # httpx2 explicitly below, with trust_env disabled. With an explicit - # `proxy=` and `trust_env=False`, httpx2 has no routing decision left to make - # differently than assumed here: the request provably goes through that - # proxy or the connection fails. NO_PROXY is therefore not evaluated in this - # mode at all — a NO_PROXY'd host is routed through the proxy rather than - # fetched direct with the blocklist already disabled, which is strictly safer - # than the alternative (see the module docstring). If no proxy is configured, - # there is nothing to route through, so refuse rather than fetch unprotected. - proxy_url = _configured_proxy_url() - if proxy_url is None: - raise SSRFError( - f"FASTMCP_SSRF_TRUST_PROXY is enabled but no HTTPS_PROXY/ALL_PROXY is " - f"configured, so the request to {hostname} would go direct with SSRF " - f"protection disabled. Set HTTPS_PROXY (or ALL_PROXY) to the trusted " - f"proxy, or unset FASTMCP_SSRF_TRUST_PROXY to restore DNS/IP " - f"validation." - ) - return ValidatedURL( - original_url=url, - hostname=hostname, - port=port, - path=path, - resolved_ips=[], - proxy_url=proxy_url, - ) - - # Resolve and validate IPs (resolve_hostname raises rather than returning [], so a - # successful return here always yields a non-empty list — see ssrf_safe_fetch_response). + # Resolve and validate IPs resolved_ips = await resolve_hostname(hostname, port) blocked = [ip for ip in resolved_ips if not is_ip_allowed(ip)] @@ -362,7 +234,7 @@ async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: original_url=url, hostname=hostname, port=port, - path=path, + path=parsed.path + ("?" + parsed.query if parsed.query else ""), resolved_ips=resolved_ips, ) @@ -433,34 +305,31 @@ async def ssrf_safe_fetch_response( last_error: Exception | None = None expected_statuses = allowed_status_codes or {200} - # One target per pinned IP in default mode; a single unpinned target in proxy mode. - targets = _build_fetch_targets(validated) - - for target in targets: + for pinned_ip in validated.resolved_ips: elapsed = time.monotonic() - start_time if elapsed > overall_timeout: raise SSRFFetchError(f"Overall timeout exceeded: {url}") remaining = max(1.0, overall_timeout - elapsed) - logger.debug("SSRF-safe fetch: %s -> %s", url, target.url) + pinned_url = ( + f"https://{format_ip_for_url(pinned_ip)}:{validated.port}{validated.path}" + ) - # In pinned mode Host is forced to the validated hostname; in proxy mode httpx - # derives it from the hostname URL. Either way, never let a caller override it. - headers: dict[str, str] = {} - if target.host_header is not None: - headers["Host"] = target.host_header + logger.debug( + "SSRF-safe fetch: %s -> %s (pinned to %s)", + url, + pinned_url, + pinned_ip, + ) + + headers = {"Host": validated.hostname} if request_headers: for key, value in request_headers.items(): + # Host must remain pinned to the validated hostname. if key.lower() == "host": continue headers[key] = value - # Pin SNI to the hostname when connecting to an IP literal; in proxy mode httpx - # derives SNI from the URL, so no override is sent. - extensions: dict[str, str] = {} - if target.sni_hostname is not None: - extensions["sni_hostname"] = target.sni_hostname - try: # Use httpx with streaming to enforce size limit during download async with ( @@ -473,19 +342,12 @@ async def ssrf_safe_fetch_response( ), follow_redirects=False, verify=True, - # Default (pinned) mode has no proxy_url and keeps trust_env's - # normal default (True). Proxy-trust mode sets an explicit - # proxy_url and turns trust_env off, so httpx2 has no environment - # -based routing decision left to make — see validate_url() above - # for why that matters. - proxy=target.proxy_url, - trust_env=target.proxy_url is None, ) as client, client.stream( "GET", - target.url, + pinned_url, headers=headers, - extensions=extensions, + extensions={"sni_hostname": validated.hostname}, ) as response, ): if time.monotonic() - start_time > overall_timeout: @@ -537,4 +399,4 @@ async def ssrf_safe_fetch_response( raise SSRFFetchError(f"Timeout fetching {url}") from last_error raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error - raise SSRFFetchError(f"Error fetching {url}: no fetch targets succeeded") + raise SSRFFetchError(f"Error fetching {url}: no resolved IPs succeeded") diff --git a/fastmcp_slim/fastmcp/server/completions.py b/fastmcp_slim/fastmcp/server/completions.py deleted file mode 100644 index a8a49a13d..000000000 --- a/fastmcp_slim/fastmcp/server/completions.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Server-side argument completion for FastMCP. - -A completion request names a reference — a specific prompt or resource -template — and the argument being completed, plus a context of the argument -values already supplied. The server answers with candidate string values. - -FastMCP surfaces this as a single server-level handler registered with -``@mcp.completion``, mirroring the MCP SDK's own ``completion/complete`` shape -and FastMCP's client-side ``Client.complete()``. The handler receives the -reference, the argument, and the optional context, and returns candidates for -whichever reference/argument pair it recognizes. -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable - -import mcp_types - -CompletionReference = mcp_types.PromptReference | mcp_types.ResourceTemplateReference -"""The reference a completion request targets: a prompt or a resource template.""" - -CompletionValues = mcp_types.Completion | list[str] | tuple[str, ...] | None -"""What a completion handler may return. - -- ``Completion`` — used verbatim (carries the optional ``total`` / ``has_more`` - pagination hints). -- ``list[str]`` / ``tuple[str, ...]`` — wrapped into a ``Completion``. A bare - ``str`` is deliberately excluded: it satisfies ``Sequence[str]`` but is almost - always a mistake, and ``normalize_completion`` rejects it at runtime — naming - concrete collections keeps the annotation and the runtime guard in agreement. -- ``None`` — treated as "no candidates" (an empty completion). -""" - -CompletionHandler = Callable[ - [ - CompletionReference, - mcp_types.CompletionArgument, - mcp_types.CompletionContext | None, - ], - Awaitable[CompletionValues] | CompletionValues, -] -"""A server's completion handler. - -Called with the reference, the argument being completed, and the optional -context of already-supplied argument values. May be sync or async. -""" - - -# The MCP completion contract caps `values` at 100 candidates per response. -MAX_COMPLETION_VALUES = 100 - - -def normalize_completion(result: CompletionValues) -> mcp_types.Completion: - """Coerce a handler's return value into a wire ``Completion``. - - A returned ``str`` is rejected: it is almost always a mistake (the value - would iterate into one-character candidates), so it raises rather than - silently producing surprising output. - - The MCP contract caps a completion at 100 values, so a longer result is - truncated to the first 100 with ``has_more`` set — a handler that returns - thousands of matches emits a conforming response rather than an oversized - one that strict clients reject. - """ - if result is None: - return mcp_types.Completion(values=[]) - if isinstance(result, str): - raise TypeError( - "A completion handler returned a str; return a list of strings " - "(for example, [value]) or a Completion instead." - ) - if isinstance(result, mcp_types.Completion): - completion = result - else: - completion = mcp_types.Completion(values=list(result)) - - if len(completion.values) > MAX_COMPLETION_VALUES: - total = ( - completion.total if completion.total is not None else len(completion.values) - ) - return mcp_types.Completion( - values=completion.values[:MAX_COMPLETION_VALUES], - total=total, - has_more=True, - ) - return completion diff --git a/fastmcp_slim/fastmcp/server/context.py b/fastmcp_slim/fastmcp/server/context.py index 0e3ccf9d5..e2c2f1977 100644 --- a/fastmcp_slim/fastmcp/server/context.py +++ b/fastmcp_slim/fastmcp/server/context.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +import warnings import weakref -from collections.abc import Callable, Generator, Mapping +from collections.abc import Callable, Generator, Mapping, Sequence from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass @@ -14,6 +15,9 @@ from mcp import LoggingLevel, ServerSession from mcp.server.context import ServerRequestContext from mcp_types import ( GetPromptResult, + ModelPreferences, + Root, + SamplingMessage, ) from mcp_types import Prompt as SDKPrompt from mcp_types import Resource as SDKResource @@ -22,7 +26,8 @@ from pydantic.networks import AnyUrl from typing_extensions import TypeVar from uncalled_for import SharedContext -from fastmcp.exceptions import ToolError +import fastmcp +from fastmcp.exceptions import FastMCPDeprecationWarning, ToolError from fastmcp.resources.base import ResourceResult from fastmcp.server.dependencies import FastMCPRequestContext, fastmcp_request_ctx from fastmcp.server.elicitation import ( @@ -33,6 +38,11 @@ from fastmcp.server.elicitation import ( parse_elicit_response_type, ) from fastmcp.server.low_level import client_supports_extension +from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool +from fastmcp.server.sampling.run import ( + sample_impl, + sample_step_impl, +) from fastmcp.server.server import FastMCP, StateValue from fastmcp.server.transforms.visibility import ( Visibility, @@ -65,6 +75,30 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG") T = TypeVar("T", default=Any) +ResultT = TypeVar("ResultT", default=str) + +# Import ToolChoiceOption from sampling module (after other imports) +from fastmcp.server.sampling.run import ToolChoiceOption # noqa: E402 + +# Warn-once guard for the sampling deprecation. Server-initiated createMessage +# was removed from MCP as of 2026-07-28 (SEP-2577); the warning fires a single +# time per process to flag that ctx.sample/ctx.sample_step are on their way out. +# A mutable set (mutated in place, never rebound) rather than a `global` boolean +# so the warn-once state is unambiguously read and written from the module. +_sample_deprecation_warned: set[bool] = set() + +_SAMPLING_DEPRECATION_MESSAGE = ( + "ctx.sample() and ctx.sample_step() are deprecated and will be removed in a " + "future FastMCP release. They rely on server-initiated createMessage " + "requests, which were removed from MCP as of 2026-07-28 (SEP-2577), so they " + "work only on session-based (handshake-era) connections. Call an LLM " + "directly from your server instead." +) + +_SAMPLING_MODERN_ERROR = ( + "server-initiated sampling is not available on MCP 2026-07-28 connections; " + "SEP-2577 removed it — call an LLM from your server instead." +) _ELICIT_MODERN_ERROR = ( "elicitation via server-initiated requests is unavailable on 2026-07-28 " @@ -72,22 +106,24 @@ _ELICIT_MODERN_ERROR = ( ) +def _warn_sampling_deprecated() -> None: + """Emit the sampling deprecation warning once per process. + + Gated on ``settings.deprecation_warnings`` like every other FastMCP + deprecation; fires a single time (module-level flag) rather than per call. + """ + if _sample_deprecation_warned or not fastmcp.settings.deprecation_warnings: + return + _sample_deprecation_warned.add(True) + warnings.warn( + _SAMPLING_DEPRECATION_MESSAGE, + FastMCPDeprecationWarning, + stacklevel=3, + ) + + _current_context: ContextVar[Context | None] = ContextVar("context", default=None) - -#: Error raised when a tool calls ``ctx.elicit()`` inside a background task. -#: Background tasks gather input with the guard/return pattern (return an -#: ``InputRequiredResult``), which the end-and-reenter machinery drives across -#: worker legs. Imperative elicitation would require blocking a worker on a -#: client round-trip, which end-and-reenter deliberately does not do. -_TASK_ELICIT_ERROR = ( - "Imperative ctx.elicit() is not supported inside a background task. Gather " - "input with the guard pattern instead: return an InputRequiredResult from " - "the tool (with input_requests), and read ctx.input_responses / " - "ctx.request_state when the task re-runs after the client answers." -) - - TransportType = Literal["stdio", "sse", "streamable-http"] _current_transport: ContextVar[TransportType | None] = ContextVar( "transport", default=None @@ -211,21 +247,14 @@ class Context: self._origin_request_id: str | None = origin_request_id # Request-scoped state for non-serializable values (serializable=False) self._request_state: dict[str, Any] = {} - # Multi-round-trip input carried in-task (SEP-2322 guard channel). A - # foreground round recovers `input_responses`/`request_state` from the - # wire request; a worker has no wire request, so the tasks extension's - # in-task loop sets these between rounds and the properties below fall - # back to them. The guard tool reads `ctx.input_responses` identically - # in both modes — only the transport differs (task store vs wire params). - self._task_input_responses: mcp_types.InputResponses | None = None - self._task_request_state: str | None = None @property def is_background_task(self) -> bool: """True when this context is running in a background task (Docket worker). - When True, certain operations like elicit() will use task-aware - implementations that can pause the task and wait for client input. + When True, certain operations like elicit() and sample() will use + task-aware implementations that can pause the task and wait for + client input. Example: ```python @@ -279,10 +308,26 @@ class Context: self._tokens.append(token) # Set current server for dependency injection (use weakref to avoid reference cycles) - from fastmcp.server.dependencies import _current_server, is_docket_available + from fastmcp.server.dependencies import ( + _current_docket, + _current_server, + _current_worker, + is_docket_available, + ) self._server_token = _current_server.set(weakref.ref(self.fastmcp)) + # Re-set docket/worker from the server instance so mounted children + # inherit the parent's Docket via the ContextVar. Only servers that + # own the Docket (the parent) have _docket set; children skip this, + # leaving the parent's value in place. + if is_docket_available(): + server = self.fastmcp + if server._docket is not None: + self._docket_token = _current_docket.set(server._docket) + if server._worker is not None: + self._worker_token = _current_worker.set(server._worker) + if not is_docket_available(): # Without docket, the lifespan won't provide a SharedContext, # so create one scoped to this Context for Shared() dependencies. @@ -293,8 +338,18 @@ class Context: async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: """Exit the context manager and reset the most recent token.""" - from fastmcp.server.dependencies import _current_server + from fastmcp.server.dependencies import ( + _current_docket, + _current_server, + _current_worker, + ) + if hasattr(self, "_worker_token"): + _current_worker.reset(self._worker_token) + del self._worker_token + if hasattr(self, "_docket_token"): + _current_docket.reset(self._docket_token) + del self._docket_token if hasattr(self, "_shared_context"): await self._shared_context.__aexit__(exc_type, exc_val, exc_tb) del self._shared_context @@ -334,86 +389,6 @@ class Context: """ return fastmcp_request_ctx.get() - def client_extension_settings(self, identifier: str) -> dict[str, Any] | None: - """This request's per-request opt-in settings for an MCP extension. - - SEP-2133 extensions negotiate per request: the client repeats its - extension capabilities in each request's ``_meta`` under - ``io.modelcontextprotocol/clientCapabilities`` → ``extensions`` → - ``identifier``. Returns the declared settings dict (possibly empty) when - the extension was opted in for this request, or ``None`` when it was - not (or there is no active request). This bridges an extension's - ``tools/call`` interceptor — which receives a FastMCP ``Context`` — to - the request's declared client capabilities. - """ - rc = self.request_context - if rc is None: - return None - from fastmcp.server.extensions import _extract_client_extension_settings - - return _extract_client_extension_settings(rc.meta, identifier) - - def _input_response_params( - self, - ) -> mcp_types.InputResponseRequestParams | None: - """The active request's multi-round-trip fields (SEP-2322), if any. - - Reads the raw params of the active wire request through the SDK's - per-request context and reparses them as `InputResponseRequestParams` - to recover the typed `input_responses` / `request_state`. The - framework's request-state boundary has already unsealed `requestState` - into plaintext by the time a handler observes it. Returns `None` - outside a wire request (e.g. a background task) or when the params are - not a mapping. - """ - rc = self.request_context - if rc is None: - return None - params = rc._srctx.params - if not isinstance(params, Mapping): - return None - return mcp_types.InputResponseRequestParams.model_validate(dict(params)) - - @property - def input_responses(self) -> mcp_types.InputResponses | None: - """Client responses to a prior `InputRequiredResult.input_requests`. - - The multi-round-trip guard channel (SEP-2322). A guard tool inspects - this to decide what to do on each round: `None` on the initial round - (nothing has been asked yet, or the client retried without responses), - so the tool returns an `InputRequiredResult` to ask; present on a later - round, so the tool reads the answers and proceeds. It is a mapping whose - keys match the `input_requests` map the tool minted; each value is the - client's result for that request (an `ElicitResult`, `CreateMessageResult`, - or `ListRootsResult`). - - In a background task there is no wire request, so this falls back to the - responses the in-task guard loop delivered (see the tasks extension). - """ - params = self._input_response_params() - if params is not None and params.input_responses is not None: - return params.input_responses - return self._task_input_responses - - @property - def request_state(self) -> str | None: - """Opaque state echoed from a prior `InputRequiredResult.request_state`. - - The multi-round-trip guard channel (SEP-2322): whatever a tool put in - `InputRequiredResult.request_state` on an earlier round is handed back - here (as plaintext — the framework seals it on the wire and unseals it - before the tool runs, so tampering is rejected before this is read). - `None` on the initial round. Use it to carry a small amount of computed - state across rounds without re-deriving it. - - In a background task there is no wire request, so this falls back to the - state the in-task guard loop re-injected (see the tasks extension). - """ - params = self._input_response_params() - if params is not None and params.request_state is not None: - return params.request_state - return self._task_request_state - @property def lifespan_context(self) -> dict[str, Any]: """Access the server's lifespan context. @@ -738,15 +713,6 @@ class Context: elif self._session is not None: session = self._session else: - # Background task: no live session, but the submitting request's - # stable session id was captured in the task snapshot. Use it so - # session-scoped state (session_id / get_state / set_state) keeps - # working in a worker, keyed to the same client that submitted. - from fastmcp.server.dependencies import _background_task_session_id - - task_session_id = _background_task_session_id.get() - if task_session_id is not None: - return task_session_id raise RuntimeError( "session_id is not available because no session exists. " "This typically means you're outside a request context." @@ -881,6 +847,13 @@ class Context: extra=extra, ) + async def list_roots(self) -> list[Root]: + """List the roots available to the server, as indicated by the client.""" + # Deprecated upstream in SDK v2 but deliberately kept per compat directive; + # removed with the multi-round-trip follow-up. + result = await self.session.list_roots() # ty: ignore[deprecated] + return result.roots + async def send_notification( self, notification: mcp_types.ServerNotification ) -> None: @@ -891,15 +864,7 @@ class Context: """ # v2: ServerNotification is a union of concrete notification models; # ServerSession.send_notification takes an instance directly (no wrapper). - # - # Relate the notification to the in-flight request so it rides that - # request's own stream. A sessionless (2026-07-28) connection has no - # standing server→client channel, so an unrelated notification is - # dropped; the request's stream is the only way out. Session-based eras - # deliver it either way. - await self.session.send_notification( - notification, related_request_id=self.request_id - ) + await self.session.send_notification(notification) async def close_sse_stream(self) -> None: """Close the current response stream to trigger client reconnection. @@ -954,6 +919,242 @@ class Context: return False return rc.protocol_version in MODERN_PROTOCOL_VERSIONS + def _server_can_sample(self) -> bool: + """True when a server-configured sampling handler can serve the request + without the client back-channel. + + FastMCP supports a server-side sampling handler (``FastMCP(sampling_handler=...)``). + With ``sampling_handler_behavior="always"`` the handler always answers; + with ``"fallback"`` it answers whenever the client cannot. On modern + connections the client back-channel is gone, so either configuration lets + the server answer entirely server-side as long as a handler is set. (For + ``"always"`` without a handler the sampling implementation raises its own + clear "no handler configured" error, which is not an era concern.) + """ + fastmcp = self.fastmcp + if fastmcp.sampling_handler_behavior == "always": + return True + return fastmcp.sampling_handler is not None + + async def sample_step( + self, + messages: str | Sequence[str | SamplingMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + model_preferences: ModelPreferences | str | list[str] | None = None, + tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, + tool_choice: ToolChoiceOption | str | None = None, + execute_tools: bool = True, + mask_error_details: bool | None = None, + tool_concurrency: int | None = None, + ) -> SampleStep: + """ + Make a single LLM sampling call. + + This is a stateless function that makes exactly one LLM call and optionally + executes any requested tools. Use this for fine-grained control over the + sampling loop. + + Args: + messages: The message(s) to send. Can be a string, list of strings, + or list of SamplingMessage objects. + system_prompt: Optional system prompt for the LLM. + temperature: Optional sampling temperature. + max_tokens: Maximum tokens to generate. Defaults to 512. + model_preferences: Optional model preferences. + tools: Optional list of tools the LLM can use. + tool_choice: Tool choice mode ("auto", "required", or "none"). + execute_tools: If True (default), execute tool calls and append results + to history. If False, return immediately with tool_calls available + in the step for manual execution. + mask_error_details: If True, mask detailed error messages from tool + execution. When None (default), uses the global settings value. + Tools can raise ToolError to bypass masking. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. + + Returns: + SampleStep containing: + - .response: The raw LLM response + - .history: Messages including input, assistant response, and tool results + - .is_tool_use: True if the LLM requested tool execution + - .tool_calls: List of tool calls (if any) + - .text: The text content (if any) + + Example: + messages = "Research X" + + while True: + step = await ctx.sample_step(messages, tools=[search]) + + if not step.is_tool_use: + print(step.text) + break + + # Continue with tool results + messages = step.history + """ + _warn_sampling_deprecated() + # On modern (2026-07-28) connections the client back-channel is gone + # (SEP-2577). A server-configured sampling handler can still answer + # entirely server-side; only raise the era error when nothing can serve + # the request. When modern, force the handler path (never attempt the + # dead client) by passing client_available=False. + client_available = not self._is_modern_protocol() + if not client_available and not self._server_can_sample(): + raise ToolError(_SAMPLING_MODERN_ERROR) + return await sample_step_impl( + self, + messages=messages, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + auto_execute_tools=execute_tools, + mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, + client_available=client_available, + ) + + @overload + async def sample( + self, + messages: str | Sequence[str | SamplingMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + model_preferences: ModelPreferences | str | list[str] | None = None, + tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, + result_type: type[ResultT], + mask_error_details: bool | None = None, + tool_concurrency: int | None = None, + ) -> SamplingResult[ResultT]: + """Overload: With result_type, returns SamplingResult[ResultT].""" + + @overload + async def sample( + self, + messages: str | Sequence[str | SamplingMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + model_preferences: ModelPreferences | str | list[str] | None = None, + tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, + result_type: None = None, + mask_error_details: bool | None = None, + tool_concurrency: int | None = None, + ) -> SamplingResult[str]: + """Overload: Without result_type, returns SamplingResult[str].""" + + async def sample( + self, + messages: str | Sequence[str | SamplingMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + model_preferences: ModelPreferences | str | list[str] | None = None, + tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, + result_type: type[ResultT] | None = None, + mask_error_details: bool | None = None, + tool_concurrency: int | None = None, + ) -> SamplingResult[ResultT] | SamplingResult[str]: + """ + Send a sampling request to the client and await the response. + + This method runs to completion automatically. When tools are provided, + it executes a tool loop: if the LLM returns a tool use request, the tools + are executed and the results are sent back to the LLM. This continues + until the LLM provides a final text response. + + When result_type is specified, a synthetic `final_response` tool is + created. The LLM calls this tool to provide the structured response, + which is validated against the result_type and returned as `.result`. + + For fine-grained control over the sampling loop, use sample_step() instead. + + Args: + messages: The message(s) to send. Can be a string, list of strings, + or list of SamplingMessage objects. + system_prompt: Optional system prompt for the LLM. + temperature: Optional sampling temperature. + max_tokens: Maximum tokens to generate. Defaults to 512. + model_preferences: Optional model preferences. + tools: Optional list of tools the LLM can use. Accepts plain + functions or SamplingTools. + result_type: Optional type for structured output. When specified, + a synthetic `final_response` tool is created and the LLM's + response is validated against this type. + mask_error_details: If True, mask detailed error messages from tool + execution. When None (default), uses the global settings value. + Tools can raise ToolError to bypass masking. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. + + Returns: + SamplingResult[T] containing: + - .text: The text representation (raw text or JSON for structured) + - .result: The typed result (str for text, parsed object for structured) + - .history: All messages exchanged during sampling + + Deprecated: + Server-initiated sampling relies on the createMessage back-channel, + which MCP removed as of 2026-07-28 (SEP-2577). This method works only + on session-based (handshake-era) connections and will be removed in a + future FastMCP release. Call an LLM directly from your server instead. + """ + _warn_sampling_deprecated() + # On modern (2026-07-28) connections the client back-channel is gone + # (SEP-2577). A server-configured sampling handler can still answer + # entirely server-side; only raise the era error when nothing can serve + # the request. When modern, force the handler path (never attempt the + # dead client) by passing client_available=False. + client_available = not self._is_modern_protocol() + if not client_available and not self._server_can_sample(): + raise ToolError(_SAMPLING_MODERN_ERROR) + return await sample_impl( # ty: ignore[invalid-return-type] + self, + messages=messages, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + model_preferences=model_preferences, + tools=tools, + result_type=result_type, + mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, + client_available=client_available, + ) + + @overload + async def elicit( + self, + message: str, + response_type: None, + *, + response_title: str | None = None, + response_description: str | None = None, + ) -> ( + AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation + ): ... + + """When response_type is None, the accepted elicitation will contain an + empty dict""" + @overload async def elicit( self, @@ -962,8 +1163,10 @@ class Context: *, response_title: str | None = None, response_description: str | None = None, - ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: - """The accepted elicitation will contain the response data""" + ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ... + + """When response_type is not None, the accepted elicitation will contain the + response data""" @overload async def elicit( @@ -973,9 +1176,10 @@ class Context: *, response_title: str | None = None, response_description: str | None = None, - ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: - """When response_type is a list of strings, the accepted elicitation will - contain the selected string response""" + ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... + + """When response_type is a list of strings, the accepted elicitation will + contain the selected string response""" @overload async def elicit( @@ -985,9 +1189,10 @@ class Context: *, response_title: str | None = None, response_description: str | None = None, - ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: - """When response_type is a dict mapping keys to title dicts, the accepted - elicitation will contain the selected key""" + ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... + + """When response_type is a dict mapping keys to title dicts, the accepted + elicitation will contain the selected key""" @overload async def elicit( @@ -997,9 +1202,12 @@ class Context: *, response_title: str | None = None, response_description: str | None = None, - ) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation: - """When response_type is a list containing a list of strings (multi-select), - the accepted elicitation will contain a list of selected strings""" + ) -> ( + AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation + ): ... + + """When response_type is a list containing a list of strings (multi-select), + the accepted elicitation will contain a list of selected strings""" @overload async def elicit( @@ -1009,10 +1217,13 @@ class Context: *, response_title: str | None = None, response_description: str | None = None, - ) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation: - """When response_type is a list containing a dict mapping keys to title dicts - (multi-select with titles), the accepted elicitation will contain a list of - selected keys""" + ) -> ( + AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation + ): ... + + """When response_type is a list containing a dict mapping keys to title dicts + (multi-select with titles), the accepted elicitation will contain a list of + selected keys""" async def elicit( self, @@ -1021,7 +1232,8 @@ class Context: | list[str] | dict[str, dict[str, str]] | list[list[str]] - | list[dict[str, dict[str, str]]], + | list[dict[str, dict[str, str]]] + | None = None, *, response_title: str | None = None, response_description: str | None = None, @@ -1046,9 +1258,11 @@ class Context: "value" field will be generated for the MCP interaction and automatically deconstructed into the primitive type upon response. - ``response_type`` is required. Pass ``bool`` when all you need is a - confirmation; an empty schema leaves some clients rendering an empty, - non-functional form. + Passing ``response_type=None`` (or omitting it) is deprecated and will + be removed in a future version. The resulting empty-schema form-mode + request is ambiguous and causes some clients (e.g. VS Code) to hang on + an empty form. Pass an explicit ``response_type`` describing the data + you want back. Args: message: A human-readable message explaining what information is needed @@ -1065,11 +1279,21 @@ class Context: ``value`` field. Same scope rules as ``response_title``. Note: - Imperative elicitation is not available inside a background task - (calling it there raises a ``ToolError``). A task gathers input with - the guard pattern: return an ``InputRequiredResult`` and read - ``ctx.input_responses`` / ``ctx.request_state`` when the task re-runs. + This method works transparently in both request and background task + contexts. In background task mode (SEP-1686), it will set the task + status to "input_required" and wait for the client to provide input. """ + if response_type is None and fastmcp.settings.deprecation_warnings: + warnings.warn( + "Calling ctx.elicit() without a response_type is deprecated " + "and will be removed in a future version. The empty-schema " + "form-mode request is ambiguous under the current MCP spec " + "and causes some clients (e.g. VS Code) to render an empty, " + "non-functional form. Pass an explicit response_type " + "describing the data you expect back.", + FastMCPDeprecationWarning, + stacklevel=2, + ) config = parse_elicit_response_type( response_type, response_title=response_title, @@ -1077,22 +1301,24 @@ class Context: ) if self.is_background_task: - # Background tasks gather input with the guard/return pattern, not - # imperative elicitation — the worker never blocks on a client - # round-trip. Fail fast with the guidance to use InputRequiredResult. - raise ToolError(_TASK_ELICIT_ERROR) - # Foreground push path: server-initiated elicitation needs a back-channel, - # which the 2026-07-28 era removed (SEP-2577). Raise a clear era-aware - # error before hitting the wire instead of the SDK's opaque "Method not - # found". Handshake-era behavior is unchanged. - if self._is_modern_protocol(): - raise ToolError(_ELICIT_MODERN_ERROR) - # Standard request mode: use session.elicit directly - result = await self.session.elicit( - message=message, - requested_schema=config.schema, - related_request_id=self.request_id, - ) + # Background task mode: use task-aware elicitation + result = await self._elicit_for_task( + message=message, + schema=config.schema, + ) + else: + # Foreground push path: server-initiated elicitation needs a + # back-channel, which the 2026-07-28 era removed (SEP-2577). Raise a + # clear era-aware error before hitting the wire instead of the SDK's + # opaque "Method not found". Handshake-era behavior is unchanged. + if self._is_modern_protocol(): + raise ToolError(_ELICIT_MODERN_ERROR) + # Standard request mode: use session.elicit directly + result = await self.session.elicit( + message=message, + requested_schema=config.schema, + related_request_id=self.request_id, + ) if result.action == "accept": return handle_elicit_accept(config, result.content) @@ -1103,6 +1329,46 @@ class Context: else: raise ValueError(f"Unexpected elicitation action: {result.action}") + async def _elicit_for_task( + self, + message: str, + schema: dict[str, Any], + ) -> mcp_types.ElicitResult: + """Send an elicitation request from a background task (SEP-1686). + + This method handles elicitation when running in a Docket worker context, + where there's no active MCP request. It: + 1. Sets the task status to "input_required" + 2. Sends the elicitation request with task metadata + 3. Waits for the client to provide input via tasks/sendInput + 4. Returns the result and resumes task execution + + Args: + message: The message to display to the user + schema: The JSON schema for the expected response + + Returns: + ElicitResult with the user's response + + Raises: + RuntimeError: If not running in a background task context + """ + if not self.is_background_task: + raise RuntimeError( + "_elicit_for_task called but not in a background task context" + ) + + # Import here to avoid circular imports and optional dependency issues + from fastmcp.server.tasks.elicitation import elicit_for_task + + return await elicit_for_task( + task_id=self._task_id, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + session=self._session, + message=message, + schema=schema, + fastmcp=self.fastmcp, + ) + def _make_state_key(self, key: str) -> str: """Create session-prefixed key for state storage.""" return f"{self.session_id}:{key}" @@ -1136,9 +1402,10 @@ class Context: value=StateValue(value=value), ttl=self._STATE_TTL_SECONDS, ) - except ValueError as e: - # Pydantic raises PydanticSerializationError (a ValueError) and the - # message carries "serialize". Other ValueErrors propagate unchanged. + except Exception as e: + # Catch serialization errors from Pydantic (ValueError) or + # the key_value library (SerializationError). Both contain + # "serialize" in the message. Other exceptions propagate as-is. if "serialize" in str(e).lower(): raise TypeError( f"Value for state key {key!r} is not serializable. " @@ -1147,19 +1414,6 @@ class Context: f"request-scoped and will not persist across requests." ) from e raise - except Exception as e: - # Import the optional storage implementation only on its error path, - # rather than adding the key_value package to every server startup. - from key_value.aio.errors import SerializationError - - if not isinstance(e, SerializationError): - raise - raise TypeError( - f"Value for state key {key!r} is not serializable. " - f"Use set_state({key!r}, value, serializable=False) to store " - f"non-serializable values. Note: non-serializable state is " - f"request-scoped and will not persist across requests." - ) from e async def get_state(self, key: str) -> Any: """Get a value from the state store. diff --git a/fastmcp_slim/fastmcp/server/dependencies.py b/fastmcp_slim/fastmcp/server/dependencies.py index 32d8ba183..3e056cb37 100644 --- a/fastmcp_slim/fastmcp/server/dependencies.py +++ b/fastmcp_slim/fastmcp/server/dependencies.py @@ -1,9 +1,8 @@ """Dependency injection for FastMCP. DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket -using the uncalled-for DI engine. The docket-specific dependencies -(``CurrentDocket``, ``CurrentWorker``) and background task execution live in the -``fastmcp-tasks`` package. +using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, +CurrentWorker) and background task execution require fastmcp[tasks]. """ from __future__ import annotations @@ -11,10 +10,11 @@ from __future__ import annotations import importlib.metadata import inspect import weakref -from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Mapping +from collections.abc import AsyncGenerator, Callable, Generator, Mapping from contextlib import AsyncExitStack, asynccontextmanager, contextmanager from contextvars import ContextVar from dataclasses import dataclass +from datetime import datetime, timezone from functools import lru_cache from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable @@ -40,15 +40,14 @@ from fastmcp.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) -from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type if TYPE_CHECKING: + from docket import Docket + from docket.worker import Worker + from fastmcp.server.context import Context from fastmcp.server.server import FastMCP - from fastmcp.server.sessions import Session - -logger = get_logger(__name__) @dataclass @@ -144,11 +143,15 @@ __all__ = [ "AccessToken", "CurrentAccessToken", "CurrentContext", + "CurrentDocket", "CurrentFastMCP", "CurrentHeaders", "CurrentRequest", + "CurrentWorker", "FastMCPRequestContext", "Progress", + "TaskContextInfo", + "TaskContextSnapshot", "TokenClaim", "bind_request_context", "extract_version_spec", @@ -158,79 +161,37 @@ __all__ = [ "get_http_headers", "get_http_request", "get_server", - "get_session", + "get_task_context", + "get_task_session", "is_docket_available", + "register_task_server", + "register_task_session", + "require_docket", "resolve_dependencies", "transform_context_annotations", "without_injected_parameters", ] +# Task context lives in fastmcp.server.tasks.context; public symbols are +# re-exported here so existing imports from dependencies continue to work. +from fastmcp.server.tasks.context import ( # noqa: E402 + TaskContextInfo, + TaskContextSnapshot, + _recall_snapshot, + get_task_context, + get_task_server, + get_task_session, + register_task_server, + register_task_session, +) + _current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar( "server", default=None ) - -#: Hook installed by the tasks extension (``fastmcp-tasks``) so a ``ctx: Context`` -#: parameter resolves inside a background-task worker, where there is no -#: foreground request context. Core ships no task engine; the extension -#: registers a factory here that builds and enters a worker ``Context`` (reading -#: the task snapshot restored by the worker). ``_CurrentContext`` falls back to -#: it when no foreground context is active. ``None`` means no tasks extension, -#: so worker context injection is unavailable and the usual "no active context" -#: error applies. -_background_context_factory: Callable[[], Awaitable[Context | None]] | None = None - - -def set_background_context_factory( - factory: Callable[[], Awaitable[Context | None]] | None, -) -> None: - """Install (or clear) the background-task ``Context`` factory. - - The factory returns an already-entered ``Context`` (so ``_current_context`` - is set for cleanup) when called inside a worker, or ``None`` when there is - no task context. Passing ``None`` restores core's no-worker-fallback - behavior. - """ - global _background_context_factory - _background_context_factory = factory - - -#: Hook installed by the tasks extension so ``get_server()`` (and thus -#: ``CurrentFastMCP()``) resolves to the server a mounted task's tool lives on -#: rather than the root that started the worker (#3571). Returns that server -#: inside a worker, or ``None`` outside one. Core has no task engine, so this is -#: ``None`` unless the extension is active. -_worker_server_resolver: Callable[[], FastMCP | None] | None = None - - -def set_worker_server_resolver( - resolver: Callable[[], FastMCP | None] | None, -) -> None: - """Install (or clear) the worker-server resolver used by ``get_server()``.""" - global _worker_server_resolver - _worker_server_resolver = resolver - - -#: Headers a background task carries from its originating request. A worker has -#: no live HTTP request — especially a Redis-backed worker in a separate process -#: — so ``get_http_request()`` correctly raises there. The tasks extension sets -#: this from the task snapshot so ``get_http_headers()`` still returns the -#: submitting request's headers without fabricating a fake ``Request`` (which -#: would make ``get_http_request()``/``CurrentRequest()`` wrongly succeed). -_background_task_headers: ContextVar[dict[str, str] | None] = ContextVar( - "fastmcp_background_task_headers", default=None -) - - -#: The originating request's stable session id, carried into a background task. -#: A worker has no live session, so ``Context.session_id`` (and the session-scoped -#: ``get_state``/``set_state`` built on it) would otherwise raise. The tasks -#: extension sets this from the task snapshot so session-scoped state keyed by the -#: submitting client survives into the worker. -_background_task_session_id: ContextVar[str | None] = ContextVar( - "fastmcp_background_task_session_id", default=None -) +_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None) +_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None) # --- Docket availability check --- @@ -270,15 +231,51 @@ def is_docket_available() -> bool: return _DOCKET_AVAILABLE +def require_docket(feature: str) -> None: + """Raise ImportError with install instructions if docket not available. + + Args: + feature: Description of what requires docket (e.g., "`task=True`", + "CurrentDocket()"). Will be included in the error message. + """ + if is_docket_available(): + return + + try: + installed = importlib.metadata.version("pydocket") + except importlib.metadata.PackageNotFoundError: + installed = None + + if installed is None: + detail = ( + "FastMCP background tasks require the `tasks` extra. " + "Install with: pip install 'fastmcp[tasks]'." + ) + else: + detail = ( + f"FastMCP background tasks require pydocket>={_MIN_DOCKET_VERSION}, " + f"but pydocket {installed} is installed (likely pulled in by another " + f"package). Upgrade with: pip install -U 'pydocket>={_MIN_DOCKET_VERSION}'." + ) + + raise ImportError(f"{detail} (Triggered by {feature})") + + +# Import Progress separately — it's docket-specific, not part of uncalled-for +try: + from docket.dependencies import Progress as DocketProgress +except ImportError: + DocketProgress = None # type: ignore[assignment] # ty:ignore[invalid-assignment] + + # --- Context utilities --- def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: - """Transform injected-by-type params into Dependency-defaulted params. + """Transform ctx: Context into ctx: Context = CurrentContext(). - Transforms ALL params typed as Context (into ``= CurrentContext()``) and as - UserSession (into ``= CurrentSession()``) to use Docket's DI system, unless - they already have a Dependency-based default. + Transforms ALL params typed as Context to use Docket's DI system, + unless they already have a Dependency-based default (like CurrentContext()). This unifies the legacy type annotation DI with Docket's Depends() system, allowing both patterns to work through a single resolution path. @@ -294,7 +291,6 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: Function with modified signature (same function object, updated __signature__) """ from fastmcp.server.context import Context - from fastmcp.server.sessions import UserSession # Get the function's signature try: @@ -311,28 +307,13 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: # First pass: identify which params need transformation params_to_transform: set[str] = set() optional_context_params: set[str] = set() - session_params: set[str] = set() - optional_session_params: set[str] = set() for name, param in sig.parameters.items(): annotation = type_hints.get(name, param.annotation) - if isinstance(param.default, Dependency): - continue if is_class_member_of_type(annotation, Context): - params_to_transform.add(name) - if param.default is None: - optional_context_params.add(name) - elif is_class_member_of_type(annotation, UserSession): - # `session: UserSession` rides the same DI path as `ctx: Context`: - # injected per authenticated principal, excluded from the schema. A - # bare `session: Session` is NOT injected — only the `UserSession` - # marker keys the per-user injection. - params_to_transform.add(name) - # A `UserSession | None = None` param opts into the unauthenticated - # case: inject `None` instead of raising, mirroring optional Context. - if param.default is None: - optional_session_params.add(name) - else: - session_params.add(name) + if not isinstance(param.default, Dependency): + params_to_transform.add(name) + if param.default is None: + optional_context_params.add(name) if not params_to_transform: return fn @@ -353,20 +334,12 @@ def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: var_keyword: list[P] = [] # **kwargs (at most one) for name, param in sig.parameters.items(): - # Transform injected-by-type params by adding a Dependency default + # Transform Context params by adding CurrentContext default if name in params_to_transform: # We use CurrentContext() instead of Depends(get_context) because # get_context() returns the Context which is an AsyncContextManager, # and the DI system would try to enter it again (it's already entered) - if name in session_params: - from fastmcp.server.sessions import CurrentSession - - param = param.replace(default=CurrentSession()) - elif name in optional_session_params: - from fastmcp.server.sessions import OptionalCurrentSession - - param = param.replace(default=OptionalCurrentSession()) - elif name in optional_context_params: + if name in optional_context_params: param = param.replace(default=OptionalCurrentContext()) else: param = param.replace(default=CurrentContext()) @@ -452,9 +425,9 @@ def get_context() -> Context: def get_server() -> FastMCP: """Get the current FastMCP server instance directly. - In a background-task worker the tasks extension's resolver is consulted - first, so a mounted-child task resolves to the child server rather than the - root that started the worker (#3571). + In a background-task worker, checks the task-server map first so that + mounted-child tasks resolve to the child server (not the parent that + started the worker). Returns: The active FastMCP server @@ -462,11 +435,13 @@ def get_server() -> FastMCP: Raises: RuntimeError: If no server in context """ - resolver = _worker_server_resolver - if resolver is not None: - worker_server = resolver() - if worker_server is not None: - return worker_server + # In a task context, prefer the task-specific server mapping. + # This handles mounted-child tasks where _current_server is the parent. + task_info = get_task_context() + if task_info is not None: + task_server = get_task_server(task_info.task_id) + if task_server is not None: + return task_server server_ref = _current_server.get() if server_ref is None: @@ -477,45 +452,12 @@ def get_server() -> FastMCP: return server -async def get_session(session_id: str) -> Session: - """Resolve and validate a `Session` for an explicit `session_id`. - - Pair with a `session_id: SessionId` tool argument (the agent obtains an id - from `create_session` and passes it back). For a single per-user bucket with - nothing for the agent to pass, inject `session: UserSession` instead. - - State is keyed by `(principal, session_id)`: the authenticated principal is - the isolation wall and `session_id` organizes sessions within it. The id must - have been minted by `create_session` under the current principal; an id that - was never created, or created under a different principal, raises - `InvalidSession` rather than resolving to a fresh empty bucket (the specific - reason is logged at debug level, never returned to the caller). - - Like `get_server()`, this resolves through the task-aware server, so it needs - no foreground context — it works from a `task=True` tool's Docket worker as - well as a normal request. - """ - from fastmcp.server.sessions import InvalidSession, Session, current_principal - - session = Session( - store=get_server()._state_store, - principal=current_principal(), - session_id=session_id, - public_id=session_id, - ) - if not await session._exists(): - logger.debug( - "Rejected session id %r: no record for the current principal.", - session_id, - ) - raise InvalidSession - return session - - def get_http_request() -> Request: """Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. + In background tasks, returns a synthetic request populated with the + snapshotted headers from the originating HTTP request. """ # Try FastMCP's request context first (set during normal MCP request handling) request = None @@ -528,6 +470,33 @@ def get_http_request() -> Request: if request is None: request = _current_http_request.get() + # In Docket workers, restore a minimal request from the snapshotted + # headers. The snapshot is preloaded by restore_task_snapshot before + # user code runs, so this is a pure ContextVar read. + if request is None: + task_info = get_task_context() + snapshot = _recall_snapshot(task_info.task_id) if task_info else None + task_headers = snapshot.http_headers if snapshot else None + if task_headers: + request = Request( + { + "type": "http", + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "headers": [ + (name.encode("latin-1"), value.encode("latin-1")) + for name, value in task_headers.items() + ], + "client": None, + "server": None, + "root_path": "", + } + ) + if request is None: raise RuntimeError("No active HTTP request found.") return request @@ -580,21 +549,14 @@ def get_http_headers( headers: dict[str, str] = {} try: - source: Any = get_http_request().headers.items() + request = get_http_request() + for name, value in request.headers.items(): + lower_name = name.lower() + if lower_name not in exclude_headers: + headers[lower_name] = str(value) + return headers except RuntimeError: - # No live request: inside a background-task worker, fall back to the - # headers the task carried from its originating request (set by the - # tasks extension from the snapshot). Empty elsewhere. - task_headers = _background_task_headers.get() - if task_headers is None: - return {} - source = task_headers.items() - - for name, value in source: - lower_name = name.lower() - if lower_name not in exclude_headers: - headers[lower_name] = str(value) - return headers + return {} def get_access_token() -> AccessToken | None: @@ -603,7 +565,8 @@ def get_access_token() -> AccessToken | None: This function first tries to get the token from the current HTTP request's scope, which is more reliable for long-lived connections where the SDK's auth_context_var may become stale after token refresh. Falls back to the SDK's context var if no - request is available. + request is available. In background tasks (Docket workers), falls back to the + token snapshot stored in Redis at task submission time. Returns: The access token if an authenticated user is available, None otherwise. @@ -626,6 +589,19 @@ def get_access_token() -> AccessToken | None: if access_token is None: access_token = _sdk_get_access_token() + # Fall back to background task snapshot (#3095). In Docket workers, + # neither the HTTP request nor the SDK context var is available; the + # snapshot is preloaded by restore_task_snapshot before user code runs. + if access_token is None: + task_info = get_task_context() + snapshot = _recall_snapshot(task_info.task_id) if task_info else None + if snapshot is not None and snapshot.access_token_json is not None: + task_token = AccessToken.model_validate_json(snapshot.access_token_json) + if task_token.expires_at is not None: + if task_token.expires_at < int(datetime.now(timezone.utc).timestamp()): + return None + return task_token + if access_token is None or isinstance(access_token, AccessToken): return access_token @@ -642,7 +618,6 @@ def get_access_token() -> AccessToken | None: # Optional fields expires_at=access_token_as_dict.get("expires_at"), resource=access_token_as_dict.get("resource"), - subject=access_token_as_dict.get("subject"), claims=access_token_as_dict.get("claims") or {}, ) except Exception as e: @@ -867,35 +842,53 @@ async def resolve_dependencies( class _CurrentContext(Dependency["Context"]): """Async context manager for Context dependency. - Returns the active context from _current_context (normal MCP request). + In foreground (request) mode: returns the active context from _current_context. + In background (Docket worker) mode: creates a task-aware Context with task_id + and loads the unified task snapshot from Redis. The shared default instance is a stateless factory. All per-invocation - state lives on the returned Context, so concurrent calls never share - mutable state. + state lives on the returned Context or in task-local ContextVars, so + concurrent tasks never share mutable state. """ async def __aenter__(self) -> Context: - from fastmcp.server.context import _current_context + from fastmcp.server.context import Context, _current_context # Try foreground context first (normal MCP request) context = _current_context.get() if context is not None: return context - # In a background-task worker there is no foreground context; the tasks - # extension installs a factory that builds and enters a worker Context - # from the restored task snapshot. Core has no task engine of its own, - # so this is None unless the extension is active. - factory = _background_context_factory - if factory is not None: - background = await factory() - if background is not None: - return background + # Check if we're in a Docket worker context + task_info = get_task_context() + if task_info is not None: + server = get_server() + + # The snapshot is preloaded by restore_task_snapshot (worker-level + # Docket dependency) before any task code runs, so this is a pure + # ContextVar read — no Redis I/O here. + snapshot = _recall_snapshot(task_info.task_id) + origin_request_id = snapshot.origin_request_id if snapshot else None + + # Session ID is stored in the snapshot for notification delivery + snapshot_session_id = snapshot.session_id if snapshot else None + session = ( + get_task_session(snapshot_session_id) if snapshot_session_id else None + ) + + ctx = Context( + fastmcp=server, + session=session, + task_id=task_info.task_id, + origin_request_id=origin_request_id, + ) + await ctx.__aenter__() + return ctx raise RuntimeError( "No active context found. This can happen if:\n" " - Called outside an MCP request handler\n" - " - Called in a background task before the context was established\n" + " - Called in a background task before session was registered\n" "Check `context.request_context` for None before accessing." ) @@ -972,6 +965,118 @@ def OptionalCurrentContext() -> Context | None: return cast("Context | None", _OptionalCurrentContext()) +class _CurrentDocket(Dependency["Docket"]): + """Async context manager for Docket dependency.""" + + async def __aenter__(self) -> Docket: + require_docket("CurrentDocket()") + # Check server instance first, fall back to ContextVar for mounted children + # whose parent owns the Docket + try: + docket = get_server()._docket + except RuntimeError: + docket = None + if docket is None: + docket = _current_docket.get() + if docket is None: + raise RuntimeError( + "No Docket instance found. Docket is only initialized when there are " + "task-enabled components (task=True). Add task=True to a component " + "to enable Docket infrastructure." + ) + return docket + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + pass + + +def CurrentDocket() -> Docket: + """Get the current Docket instance managed by FastMCP. + + This dependency provides access to the Docket instance that FastMCP + automatically creates for background task scheduling. + + Returns: + A dependency that resolves to the active Docket instance + + Raises: + RuntimeError: If not within a FastMCP server context + ImportError: If fastmcp[tasks] not installed + + Example: + ```python + from fastmcp.dependencies import CurrentDocket + + @mcp.tool() + async def schedule_task(docket: Docket = CurrentDocket()) -> str: + await docket.add(some_function)(arg1, arg2) + return "Scheduled" + ``` + """ + require_docket("CurrentDocket()") + return cast("Docket", _CurrentDocket()) + + +class _CurrentWorker(Dependency["Worker"]): + """Async context manager for Worker dependency.""" + + async def __aenter__(self) -> Worker: + require_docket("CurrentWorker()") + # Check server instance first, fall back to ContextVar for mounted children + try: + worker = get_server()._worker + except RuntimeError: + worker = None + if worker is None: + worker = _current_worker.get() + if worker is None: + raise RuntimeError( + "No Worker instance found. Worker is only initialized when there are " + "task-enabled components (task=True). Add task=True to a component " + "to enable Docket infrastructure." + ) + return worker + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + pass + + +def CurrentWorker() -> Worker: + """Get the current Docket Worker instance managed by FastMCP. + + This dependency provides access to the Worker instance that FastMCP + automatically creates for background task processing. + + Returns: + A dependency that resolves to the active Worker instance + + Raises: + RuntimeError: If not within a FastMCP server context + ImportError: If fastmcp[tasks] not installed + + Example: + ```python + from fastmcp.dependencies import CurrentWorker + + @mcp.tool() + async def check_worker_status(worker: Worker = CurrentWorker()) -> str: + return f"Worker: {worker.name}" + ``` + """ + require_docket("CurrentWorker()") + return cast("Worker", _CurrentWorker()) + + class _CurrentFastMCP(Dependency["FastMCP"]): """Async context manager for FastMCP server dependency.""" diff --git a/fastmcp_slim/fastmcp/server/elicitation.py b/fastmcp_slim/fastmcp/server/elicitation.py index 5eaccf399..8a3fe0a5e 100644 --- a/fastmcp_slim/fastmcp/server/elicitation.py +++ b/fastmcp_slim/fastmcp/server/elicitation.py @@ -129,17 +129,6 @@ class ElicitConfig: is_raw: bool -#: Raised when a response type is missing. The empty-object schema this used to -#: produce was ambiguous under the MCP spec and left some clients (e.g. VS Code) -#: rendering an empty, non-functional form. Deprecated in 3.2, removed in 4.0. -_NONE_RESPONSE_TYPE_ERROR = ( - "ctx.elicit() requires a response_type. The empty-schema form-mode request " - "produced by response_type=None was ambiguous under the MCP spec and caused " - "some clients to render an empty, non-functional form. Pass a type " - "describing the data you expect back — use `bool` for a confirmation." -) - - def parse_elicit_response_type( response_type: Any, response_title: str | None = None, @@ -147,8 +136,8 @@ def parse_elicit_response_type( ) -> ElicitConfig: """Parse response_type into schema and handling configuration. - A response type is required; ``None`` raises ``TypeError``. Supports - multiple syntaxes: + Supports multiple syntaxes: + - None: Empty object schema, expect empty response - dict: `{"low": {"title": "..."}}` -> single-select titled enum - list patterns: - `[["a", "b"]]` -> multi-select untitled @@ -161,16 +150,26 @@ def parse_elicit_response_type( The ``response_title`` and ``response_description`` arguments customize the label and description of the wrapped ``value`` property for the scalar/dict/list shorthand forms. They are only valid when FastMCP is wrapping the response - type; passing them with a full BaseModel/dataclass raises ``TypeError``, - because in those cases the user already controls field metadata via - ``Field(title=..., description=...)``. + type; passing them with a full BaseModel/dataclass (or ``None``) raises + ``TypeError``, because in those cases the user already controls field + metadata via ``Field(title=..., description=...)``. """ has_response_metadata = ( response_title is not None or response_description is not None ) if response_type is None: - raise TypeError(_NONE_RESPONSE_TYPE_ERROR) + if has_response_metadata: + raise TypeError( + "response_title and response_description are not supported when " + "response_type is None, because the elicitation schema has no " + "fields to label." + ) + return ElicitConfig( + schema={"type": "object", "properties": {}}, + response_type=None, + is_raw=False, + ) if isinstance(response_type, dict): config = _parse_dict_syntax(response_type) diff --git a/fastmcp_slim/fastmcp/server/event_store.py b/fastmcp_slim/fastmcp/server/event_store.py index a7efc4fdc..86897aac7 100644 --- a/fastmcp_slim/fastmcp/server/event_store.py +++ b/fastmcp_slim/fastmcp/server/event_store.py @@ -8,7 +8,6 @@ AsyncKeyValue protocol, allowing users to configure any compatible backend from __future__ import annotations -import asyncio from uuid import uuid4 from key_value.aio.adapters.pydantic import PydanticAdapter @@ -19,9 +18,6 @@ from mcp.server.streamable_http import EventStore as SDKEventStore from mcp_types import JSONRPCMessage from pydantic import TypeAdapter -from fastmcp.server.session_scoped_event_store import ( - SessionScopedEventStore as SessionScopedEventStore, -) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel @@ -31,9 +27,6 @@ logger = get_logger(__name__) # TypeAdapter to validate a stored dict back into the correct member. _jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) -# Number of striped locks guarding stream event lists. See EventStore.__init__. -_LOCK_STRIPES = 64 - class EventEntry(FastMCPBaseModel): """Stored event entry.""" @@ -49,6 +42,58 @@ class StreamEventList(FastMCPBaseModel): event_ids: list[str] +class SessionScopedEventStore(SDKEventStore): + """EventStore adapter that isolates stream IDs to one transport session.""" + + def __init__(self, event_store: SDKEventStore, session_id: str): + self._event_store = event_store + self._stream_prefix = f"{len(session_id)}:{session_id}:" + + def _scope_stream_id(self, stream_id: StreamId) -> StreamId: + return f"{self._stream_prefix}{stream_id}" + + def _unscope_stream_id(self, stream_id: StreamId) -> StreamId | None: + if not stream_id.startswith(self._stream_prefix): + return None + return stream_id[len(self._stream_prefix) :] + + async def store_event( + self, stream_id: StreamId, message: JSONRPCMessage | None + ) -> EventId: + return await self._event_store.store_event( + self._scope_stream_id(stream_id), message + ) + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + replayed_events: list[EventMessage] = [] + + async def buffer_event(event: EventMessage) -> None: + replayed_events.append(event) + + scoped_stream_id = await self._event_store.replay_events_after( + last_event_id, buffer_event + ) + if scoped_stream_id is None: + return None + + stream_id = self._unscope_stream_id(scoped_stream_id) + if stream_id is None: + logger.warning( + "Event ID %s does not belong to this session-scoped event store", + last_event_id, + ) + return None + + for event in replayed_events: + await send_callback(event) + + return stream_id + + class EventStore(SDKEventStore): """EventStore implementation backed by AsyncKeyValue. @@ -88,20 +133,6 @@ class EventStore(SDKEventStore): self._storage: AsyncKeyValue = storage or MemoryStore() self._max_events_per_stream = max_events_per_stream self._ttl = ttl - # Serializes the read-modify-write of each stream's event list. A fixed - # set of striped locks rather than one lock per stream: a single store is - # shared by every session, so a store-wide lock would serialize unrelated - # streams across a Redis round-trip, while a per-stream map would grow - # with every session and need its own eviction. Two streams only contend - # when their IDs collide on the same stripe. - # - # In-process locks are enough because a stream list only ever has - # in-process writers: every transport gets its own SessionScopedEventStore - # with a random per-session prefix, so no two servers sharing one backend - # address the same stream key. Coordinating across processes would need a - # compare-and-swap or transactional update, which AsyncKeyValue does not - # expose -- it offers only get/put/delete/ttl. - self._stream_locks = tuple(asyncio.Lock() for _ in range(_LOCK_STRIPES)) # PydanticAdapter for type-safe storage (following OAuth proxy pattern) self._event_store: PydanticAdapter[EventEntry] = PydanticAdapter[EventEntry]( @@ -139,27 +170,22 @@ class EventStore(SDKEventStore): ) await self._event_store.put(key=event_id, value=entry, ttl=self._ttl) - # Update stream's event list. A session stores events from more than one - # task -- the SSE writer and the message router both do -- so this - # read-modify-write has to be serialized. Interleaved, each task reads the - # same list, appends only its own ID, and the later write drops the other - # event entirely while both tasks evict the same expired IDs. - async with self._stream_locks[hash(stream_id) % _LOCK_STRIPES]: - stream_data = await self._stream_store.get(key=stream_id) - event_ids = stream_data.event_ids if stream_data else [] - event_ids.append(event_id) + # Update stream's event list + stream_data = await self._stream_store.get(key=stream_id) + event_ids = stream_data.event_ids if stream_data else [] + event_ids.append(event_id) - # Trim to max events (delete old events) - if len(event_ids) > self._max_events_per_stream: - for old_id in event_ids[: -self._max_events_per_stream]: - await self._event_store.delete(key=old_id) - event_ids = event_ids[-self._max_events_per_stream :] + # Trim to max events (delete old events) + if len(event_ids) > self._max_events_per_stream: + for old_id in event_ids[: -self._max_events_per_stream]: + await self._event_store.delete(key=old_id) + event_ids = event_ids[-self._max_events_per_stream :] - await self._stream_store.put( - key=stream_id, - value=StreamEventList(event_ids=event_ids), - ttl=self._ttl, - ) + await self._stream_store.put( + key=stream_id, + value=StreamEventList(event_ids=event_ids), + ttl=self._ttl, + ) return event_id diff --git a/fastmcp_slim/fastmcp/server/extensions.py b/fastmcp_slim/fastmcp/server/extensions.py deleted file mode 100644 index 91de88d5a..000000000 --- a/fastmcp_slim/fastmcp/server/extensions.py +++ /dev/null @@ -1,297 +0,0 @@ -"""FastMCP-native server extension API (SEP-2133). - -An MCP extension is an opt-in, capability-negotiated bundle of protocol -behaviour identified by a reverse-DNS string (e.g. `io.modelcontextprotocol/tasks`). -Unlike the SDK's `mcp.server.extension.Extension`, a FastMCP `ServerExtension` -is bound to its `FastMCP` instance at registration, so its request handlers and -its `tools/call` interceptor can reach the component registry, `Context`, and -auth scope that the SDK's model withholds. - -An extension contributes any subset of four things: - -- **A negotiated capability.** `settings()` is spliced into - `ServerCapabilities.extensions[identifier]` (see `LowLevelServer.get_capabilities`). -- **New request methods.** `methods()` returns `MethodBinding`s, each wired onto - the low-level server via `add_request_handler` when the extension is registered. -- **A `tools/call` interceptor.** `intercept_tool_call()` is the last gate before - a tool body runs — it composes *after* the FastMCP middleware chain and *before* - component execution, so it can observe, short-circuit, or pass a call through. -- **A lifespan.** `lifespan()` is entered with the server's lifespan and exited on - shutdown — the hook the SDK's `Extension` lacks, needed to start backends/workers. - -The base class follows the SDK's httpx-style shape: every contribution method has -a default, so a subclass overrides only what it needs. -""" - -from __future__ import annotations - -import weakref -from collections.abc import Awaitable, Callable, Mapping, Sequence -from contextlib import AbstractAsyncContextManager, nullcontext -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, TypeAlias - -from mcp.server.context import ServerRequestContext -from mcp.shared.exceptions import MCPError -from mcp.shared.extension import validate_extension_identifier -from mcp_types import ( - CLIENT_CAPABILITIES_META_KEY, - METHOD_NOT_FOUND, - CallToolRequestParams, -) -from mcp_types.methods import SPEC_CLIENT_METHODS -from pydantic import BaseModel - -from fastmcp.server.dependencies import _lift_meta, bind_request_context - -if TYPE_CHECKING: - from fastmcp.server.context import Context - from fastmcp.server.server import FastMCP - from fastmcp.tools.base import ToolResult - -__all__ = [ - "MethodBinding", - "ServerExtension", - "read_client_extension_settings", -] - -# What an extension's tools/call interceptor observes and may produce: the tool -# result, or an extension-defined wire result model (a `BaseModel` the runner -# serializes) when the call is short-circuited — e.g. the tasks extension's -# CreateTaskResult. Core does not interpret the extension's result shape. -ToolCallOutcome: TypeAlias = "ToolResult | BaseModel" - -# A method handler receives the SDK request context plus validated params and -# returns a bare result model (the runner serializes it). -ExtensionRequestHandler: TypeAlias = Callable[ - [ServerRequestContext[Any, Any], Any], - Awaitable[BaseModel | dict[str, Any] | None], -] - -# A tools/call interceptor's continuation: awaiting it runs the rest of the -# interceptor chain and, finally, the tool body. -ToolCallContinuation: TypeAlias = Callable[[], Awaitable["ToolCallOutcome"]] - - -@dataclass(frozen=True) -class MethodBinding: - """A new request method an extension serves, e.g. `tasks/get`. - - `params_type` validates incoming params before `handler` runs; it should - subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`, - when set, restricts the method to those wire versions — a request at any - other version is rejected as `METHOD_NOT_FOUND`, mirroring the spec's - `(method, version)` boundary. `None` (the default) admits every version. - - Extension methods are additive: `method` must not name a spec-defined - request method (`tools/call`, `completion/complete`, ...). Binding one would - silently shadow the server's own handler. Both constraints are enforced at - construction. - """ - - method: str - params_type: type[BaseModel] - handler: ExtensionRequestHandler - protocol_versions: frozenset[str] | None = None - - def __post_init__(self) -> None: - if self.method in SPEC_CLIENT_METHODS: - raise ValueError( - f"MethodBinding cannot bind spec method {self.method!r}; extension " - "methods are additive. Use ServerExtension.intercept_tool_call or " - "FastMCP middleware to wrap core behaviour." - ) - if self.protocol_versions is not None and not self.protocol_versions: - raise ValueError( - f"MethodBinding for {self.method!r} has an empty protocol_versions " - "set, so it could never be served; use None to admit every version." - ) - - -class ServerExtension: - """Base class for an opt-in FastMCP server extension (SEP-2133). - - Subclass, set `identifier`, and override the contribution methods that - apply. Every method has a default, so a minimal extension overrides only - `identifier` and one contribution. `identifier` is validated at - subclass-definition time when set as a class attribute, and again at - registration (which covers per-instance identifiers assigned in `__init__`). - - Register an instance with `FastMCP.add_extension(...)`, which binds the - extension to the server so `self.server`, `intercept_tool_call`, and method - handlers can reach FastMCP-level constructs. - """ - - #: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`. - identifier: str - - _server_ref: weakref.ref[FastMCP] | None = None - - def __init_subclass__(cls, **kwargs: Any) -> None: - super().__init_subclass__(**kwargs) - # A class-level identifier is validated here; a per-instance identifier - # assigned in __init__ is validated at registration instead (no class - # attribute exists to inspect at definition time). - identifier = cls.__dict__.get("identifier") - if identifier is not None: - validate_extension_identifier(identifier, owner=cls.__name__) - - def _bind(self, server: FastMCP) -> None: - """Bind this extension to its FastMCP instance (called by `add_extension`). - - A weak reference avoids a reference cycle between the server and its - extensions. Per-instance identifiers are validated here. - """ - validate_extension_identifier(self.identifier, owner=type(self).__name__) - self._server_ref = weakref.ref(server) - - @property - def server(self) -> FastMCP: - """The FastMCP server this extension is registered on. - - Handlers, interceptors, and lifespan code reach the component registry, - `Context`, and auth scope through here. Raises if the extension has not - been registered with `FastMCP.add_extension()`. - """ - ref = self._server_ref - server = ref() if ref is not None else None - if server is None: - raise RuntimeError( - f"Extension {self.identifier!r} is not bound to a FastMCP server; " - "register it with FastMCP.add_extension() before use." - ) - return server - - def settings(self) -> dict[str, Any]: - """Per-extension settings advertised at `capabilities.extensions[identifier]`. - - An empty dict (the default) advertises the extension with no settings. - """ - return {} - - def methods(self) -> Sequence[MethodBinding]: - """New request methods this extension serves (additive).""" - return () - - def lifespan(self) -> AbstractAsyncContextManager[None]: - """A context manager entered with the server's lifespan, exited on shutdown. - - Default: a no-op. Override to start and stop resources an extension owns - (a task-queue backend and worker, say). Entered once per runtime tree, at - the root — a mounted child defers to the root, as the shared Docket does. - """ - return nullcontext() - - async def intercept_tool_call( - self, - params: CallToolRequestParams, - context: Context, - call_next: ToolCallContinuation, - ) -> ToolCallOutcome: - """Wrap `tools/call`. Default: pass through unchanged. - - Runs after the FastMCP middleware chain and before the tool body, so it - is the last gate before execution. Override to observe the call, to - short-circuit (return a result without awaiting `call_next`), or to pass - it through (`return await call_next()`). `params` is the validated - `tools/call` params; `context` is the FastMCP `Context`, from which the - tool being called (`context.fastmcp.get_tool(params.name)`), auth scope, - and the server are reachable. Multiple extensions nest with the - first-registered outermost. - """ - return await call_next() - - def client_settings( - self, ctx: ServerRequestContext[Any, Any] - ) -> dict[str, Any] | None: - """This extension's per-request opt-in settings declared by the client. - - Reads the request's `_meta` client-capabilities block. Returns the - declared settings dict (possibly empty) when the client opted this - extension in for the request, or `None` when it did not. Convenience for - `read_client_extension_settings(ctx, self.identifier)`. - """ - return read_client_extension_settings(ctx, self.identifier) - - -def _extract_client_extension_settings( - meta: Mapping[str, Any] | None, identifier: str -) -> dict[str, Any] | None: - """Pull `_meta[clientCapabilities][extensions][identifier]` from a lifted meta block.""" - if not meta: - return None - client_caps = meta.get(CLIENT_CAPABILITIES_META_KEY) - if not isinstance(client_caps, Mapping): - return None - extensions = client_caps.get("extensions") - if not isinstance(extensions, Mapping): - return None - settings = extensions.get(identifier) - if isinstance(settings, Mapping): - return dict(settings) - return None - - -def read_client_extension_settings( - ctx: ServerRequestContext[Any, Any], identifier: str -) -> dict[str, Any] | None: - """Read a client's per-request extension opt-in from the request `_meta`. - - SEP-2133 extensions negotiate per request: the client repeats its extension - capabilities in each request's `_meta` under - `io.modelcontextprotocol/clientCapabilities` → `extensions` → `identifier`. - Returns the declared settings dict (possibly empty) when the extension was - opted in for this request, or `None` when it was not. - """ - return _extract_client_extension_settings(_lift_meta(ctx), identifier) - - -def build_method_handler(binding: MethodBinding) -> ExtensionRequestHandler: - """Wrap a `MethodBinding` into a low-level request handler. - - The adapter enforces `protocol_versions` gating (rejecting other versions as - `METHOD_NOT_FOUND`, since `add_request_handler` registers unconditionally) - and binds the FastMCP request context so the handler can use `get_context()`, - auth, and other request-scoped dependencies. - """ - - async def handler( - ctx: ServerRequestContext[Any, Any], params: Any - ) -> BaseModel | dict[str, Any] | None: - if ( - binding.protocol_versions is not None - and ctx.protocol_version not in binding.protocol_versions - ): - raise MCPError( - code=METHOD_NOT_FOUND, - message=( - f"Method {binding.method!r} is not available at protocol " - f"version {ctx.protocol_version!r}." - ), - ) - with bind_request_context(ctx): - return await binding.handler(ctx, params) - - return handler - - -def wrap_tool_call_interceptor( - extension: ServerExtension, - call_next: Callable[[Any], Awaitable[Any]], -) -> Callable[[Any], Awaitable[Any]]: - """Fold one extension's `intercept_tool_call` around a middleware `call_next`. - - The returned wrapper is a FastMCP `CallNext`: it hands the extension the - validated `tools/call` params, the FastMCP `Context`, and a zero-arg - continuation that runs the rest of the chain and, finally, the tool body. - """ - - async def wrapped(context: Any) -> Any: - async def cont() -> Any: - return await call_next(context) - - return await extension.intercept_tool_call( - context.message, context.fastmcp_context, cont - ) - - return wrapped diff --git a/fastmcp_slim/fastmcp/server/http.py b/fastmcp_slim/fastmcp/server/http.py index 7bd8fff99..2421badb4 100644 --- a/fastmcp_slim/fastmcp/server/http.py +++ b/fastmcp_slim/fastmcp/server/http.py @@ -27,7 +27,7 @@ from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send from fastmcp.server.auth import AuthProvider from fastmcp.server.auth.middleware import RequireAuthMiddleware -from fastmcp.server.session_scoped_event_store import SessionScopedEventStore +from fastmcp.server.event_store import SessionScopedEventStore from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: @@ -476,7 +476,6 @@ def create_sse_app( handle_sse, auth.required_scopes, resource_metadata_url, - auth.challenge_scopes, ), methods=["GET"], ) @@ -490,7 +489,6 @@ def create_sse_app( sse.handle_post_message, auth.required_scopes, resource_metadata_url, - auth.challenge_scopes, ), ) ) @@ -624,7 +622,6 @@ def create_streamable_http_app( streamable_http_app, auth.required_scopes, resource_metadata_url, - auth.challenge_scopes, ), methods=http_methods, ) diff --git a/fastmcp_slim/fastmcp/server/low_level.py b/fastmcp_slim/fastmcp/server/low_level.py index cef3ad689..5267b1c66 100644 --- a/fastmcp_slim/fastmcp/server/low_level.py +++ b/fastmcp_slim/fastmcp/server/low_level.py @@ -3,7 +3,6 @@ from __future__ import annotations import weakref from collections.abc import Iterator, Mapping from contextlib import contextmanager -from dataclasses import replace from typing import TYPE_CHECKING, Any, cast import mcp_types @@ -22,7 +21,6 @@ from mcp.server.lowlevel.server import ( Server as _Server, ) from mcp.server.models import InitializationOptions -from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity from mcp.server.session import ServerSession from mcp.server.stdio import stdio_server as stdio_server from mcp.shared.exceptions import MCPError @@ -38,75 +36,6 @@ if TYPE_CHECKING: logger = get_logger(__name__) -# The request methods that FastMCP serves through a handler adapter, each of which -# runs the FastMCP middleware chain interior (see MCPOperationsMixin). The root -# dispatch leaves these to the interior dispatch and only observes them if they fail before -# reaching it. Every other message is dispatched here at the root. -_INTERIOR_METHODS = frozenset( - { - "tools/call", - "tools/list", - "resources/read", - "resources/list", - "resources/templates/list", - "prompts/get", - "prompts/list", - } -) - - -def _raw_message(ctx: ServerRequestContext) -> Any: - """The message payload handed to the root dispatch's ``on_message``/``on_request`` pass. - - The raw inbound params mapping is used verbatim rather than a validated, - typed request model. This is deliberate: the outer pass must observe *every* - message, including malformed or unroutable ones, and reconstructing a typed - model would raise on exactly those messages and hide them from the hooks. - The method and request/notification kind are carried on the - ``MiddlewareContext`` itself, so observation middleware still has everything - it needs. - """ - params = ctx.params - if isinstance(params, Mapping): - return dict(params) - return {} if params is None else params - - -def _forward_ctx( - ctx: ServerRequestContext, mw_ctx: Any, original: Any -) -> ServerRequestContext: - """Fold middleware edits to the *message* back into the SDK context. - - The outer pass hands middleware a *copy* of the raw params (see - ``_raw_message``), so a hook that follows the documented inspect/modify - contract — mutating ``context.message`` or passing ``context.copy(message=...)`` - to ``call_next`` — would otherwise have its edits silently dropped when the - bridge dispatched the original context. Rewriting through - ``dataclasses.replace`` is how the SDK documents altering what the handler - sees. An untouched message forwards the original context unchanged. - - ``ctx.method`` is deliberately *not* rewritable here. Dispatch has already - branched on the method to decide that this message has no interior handler, - so redirecting it now — say, turning a ``ping`` into a ``tools/list`` — would - hand it to a component handler that runs the FastMCP chain a second time, - firing ``on_message`` and raw ``__call__`` overrides twice for one message - and duplicating whatever side effects (rate limiting, authorization, - logging) they carry. Rewriting the method is not part of the documented - middleware contract; only the message is. - """ - message = mw_ctx.message - if isinstance(message, Mapping): - params: Mapping[str, Any] | None = dict(message) - # `_raw_message` renders absent params as `{}`; keep that distinction so - # an untouched notification still dispatches with `params=None`. - if ctx.params is None and message == original and not message: - params = None - else: - params = ctx.params - if params == ctx.params: - return ctx - return replace(ctx, params=params) - def client_supports_extension(session: ServerSession, extension_id: str) -> bool: """Check whether the connected client supports a given MCP extension. @@ -138,41 +67,15 @@ def client_supports_extension(session: ServerSession, extension_id: str) -> bool class FastMCPServerMiddleware: - """Root dispatch for the FastMCP middleware chain, in the SDK's middleware layer. + """SDK v2 server middleware that routes ``initialize`` through FastMCP middleware. v2 no longer lets FastMCP subclass ``ServerSession`` (the runner constructs it per request), so the old ``MiddlewareServerSession._received_request`` - override is replaced by a ``ServerMiddleware`` — an ordinary entry in the - SDK's own middleware list. Sitting at the root of dispatch, this - is the single entry point through which *every* inbound message flows — - requests, notifications, cancellations, ``initialize``, and even malformed or - unroutable messages the SDK can still hand us. It binds the FastMCP - request-context ContextVar and re-applies the app-scoped ``SharedContext`` for - the whole chain, then runs the FastMCP ``Middleware`` chain so - ``on_message`` / ``on_request`` / ``on_notification`` observe the message. - - Dispatch shapes: - - - Negotiation runs the *whole* FastMCP chain here: ``initialize`` dispatches - through ``on_initialize`` and ``server/discover`` through ``on_discover``. - Neither has an interior FastMCP handler adapter, and the SDK serializes both - results before returning through its middleware seam, so this root adapter - restores core results to typed models before FastMCP middleware observes them. - - The component methods (``tools/call``, ``tools/list``, ``resources/read``, - ...) still run their FastMCP chain *interior*, in the handler adapter, where - ``on_call_tool`` receives the typed component result and a tool exception - propagates through ``on_message``/``on_request`` exactly where the built-in - error/logging/timing middleware expect it. The root dispatch does not re-run the - chain for these — it only steps in when such a request fails *before* the - interior runs (malformed params, routing), so ``on_message`` still observes - the failure. - - Every other message — all notifications (including ``notifications/cancelled`` - and ``notifications/initialized``), ``ping``, ``logging/setLevel``, and any - unroutable/non-component request — has no interior FastMCP dispatch, so the - root dispatch runs the ``"outer"`` pass (``on_message`` plus - ``on_request``/``on_notification``) here, wrapping the real SDK dispatch. - This closes the long-standing gap where these messages were invisible to - FastMCP middleware. + override is replaced by a ``ServerMiddleware``. This middleware binds the + FastMCP request-context ContextVar for the whole chain (covering + ``initialize``, where no handler adapter runs) and routes the initialize + request through the FastMCP middleware chain so ``on_initialize`` hooks fire + and can observe the ``InitializeResult`` or veto with ``MCPError``. """ def __init__(self, fastmcp: FastMCP): @@ -189,90 +92,13 @@ class FastMCPServerMiddleware: bind_request_context(ctx), self._seam_span(fastmcp, ctx), ): - if fastmcp is None: - return await call_next(ctx) + # Only initialize requests (request_id present) go through FastMCP + # middleware here; every other request already binds the context in + # its own adapter, so we just pass through. if ctx.method == "initialize" and ctx.request_id is not None: - return await self._run_initialize_mw(fastmcp, ctx, call_next) - if ctx.method == "server/discover" and ctx.request_id is not None: - return await self._run_discover_mw(fastmcp, ctx, call_next) - if ctx.request_id is not None and ctx.method in _INTERIOR_METHODS: - return await self._dispatch_component(fastmcp, ctx, call_next) - return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=None) - - async def _dispatch_component( - self, - fastmcp: FastMCP, - ctx: ServerRequestContext, - call_next: CallNext, - ) -> HandlerResult: - """Delegate a component request to the interior chain, covering early failures. - - The interior handler adapter runs the FastMCP chain itself and records - ``_interior_dispatched``. If the request instead fails before reaching it - (malformed params, method routing), the flag stays False and no hook fired - — so the root dispatch runs the ``"outer"`` pass to observe the failure, re-raising - the original error inside it so ``on_message``/``on_request`` see it. - """ - from fastmcp.server.middleware.middleware import _interior_dispatched - - token = _interior_dispatched.set(False) - try: + if fastmcp is not None: + return await self._run_initialize_mw(fastmcp, ctx, call_next) return await call_next(ctx) - except (MCPError, ValidationError) as exc: - if _interior_dispatched.get(): - raise - return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=exc) - finally: - _interior_dispatched.reset(token) - - async def _run_outer_mw( - self, - fastmcp: FastMCP, - ctx: ServerRequestContext, - call_next: CallNext, - *, - _raise: BaseException | None, - ) -> HandlerResult: - """Run the method-agnostic (``on_message``/``on_request``) hook pass. - - ``call_next`` bridges to the real SDK dispatch (request-state boundary, - params validation, the notification handler), so these hooks observe the - actual wire outcome: a notification returns ``None``, an unroutable request - raises through ``call_next``. Message edits are folded back in through - ``_forward_ctx``. - - When ``_raise`` is set the operation already failed before the interior - ran, and the bridge re-raises it rather than dispatching. This pass is - the *observation* path for that failure, not a retry: re-dispatching a - corrected component request would run its handler, which runs the FastMCP - chain interior, firing ``on_message`` and the raw ``__call__`` override a - second time for one message. A hook cannot repair a malformed - ``tools/call`` from here — it sees the failure, and the failure stands. - """ - from fastmcp.server.context import Context - from fastmcp.server.middleware.middleware import MiddlewareContext - - is_notification = ctx.request_id is None - original_message = _raw_message(ctx) - - async def root_call_next(_mw_ctx: MiddlewareContext) -> HandlerResult: - if _raise is not None: - raise _raise - return await call_next(_forward_ctx(ctx, _mw_ctx, original_message)) - - async with Context(fastmcp=fastmcp, session=ctx.session) as fastmcp_ctx: - mw_context = MiddlewareContext( - message=original_message, - source="client", - type="notification" if is_notification else "request", - method=ctx.method, - fastmcp_context=fastmcp_ctx, - ) - return await fastmcp._run_middleware( - mw_context, - cast("FastMCPCallNext[Any, Any]", root_call_next), - phase="outer", - ) @contextmanager def _seam_span( @@ -321,62 +147,6 @@ class FastMCPServerMiddleware: for var, token in reversed(tokens): var.reset(token) - async def _run_discover_mw( - self, - fastmcp: FastMCP, - ctx: ServerRequestContext, - call_next: CallNext, - ) -> HandlerResult: - """Run discovery through the typed FastMCP middleware hook.""" - from fastmcp.server.context import Context - from fastmcp.server.middleware.middleware import MiddlewareContext - - try: - discover_message = mcp_types.DiscoverRequest.model_validate( - {"method": "server/discover", "params": ctx.params}, by_name=False - ) - except ValidationError as exc: - return await self._run_outer_mw(fastmcp, ctx, call_next, _raise=exc) - - async def call_original_handler( - _mw_ctx: MiddlewareContext, - ) -> mcp_types.DiscoverResult | dict[str, Any]: - message = _mw_ctx.message - params = ( - message.params.model_dump(by_alias=True, mode="json", exclude_none=True) - if message.params is not None - else None - ) - raw = await call_next(replace(ctx, params=params)) - if isinstance(raw, mcp_types.DiscoverResult): - return raw - if isinstance(raw, Mapping): - result = dict(raw) - result_type = result.get("resultType") - if ( - isinstance(result_type, str) - and result_type not in mcp_types.CORE_RESULT_TYPES - ): - return result - return mcp_types.DiscoverResult.model_validate(result) - raise TypeError( - "server/discover handler returned " - f"{type(raw).__name__}; expected DiscoverResult or mapping" - ) - - async with Context(fastmcp=fastmcp, session=ctx.session) as fastmcp_ctx: - mw_context = MiddlewareContext( - message=discover_message, - source="client", - type="request", - method="server/discover", - fastmcp_context=fastmcp_ctx, - ) - return await fastmcp._run_middleware( - mw_context, - cast("FastMCPCallNext[Any, Any]", call_original_handler), - ) - async def _run_initialize_mw( self, fastmcp: FastMCP, @@ -486,23 +256,6 @@ class LowLevelServer(_Server[LifespanResultT]): cast("ServerMiddleware[LifespanResultT]", FastMCPServerMiddleware(fastmcp)) ) - # Install the SDK's request-state boundary (SEP-2322): it seals every - # outgoing `InputRequiredResult.request_state` at the wire and unseals - # every inbound echo *before* any handler runs, so tool bodies only ever - # see plaintext `ctx.request_state`. Mirrors `MCPServer.__init__`. When - # the server was constructed without an explicit `request_state_security` - # policy, seal under a per-process ephemeral key (single-process - # deployments); multi-replica deployments pass a shared-key policy. The - # low-level server always has a name (FastMCP autogenerates one), so the - # audience claim is always populated. - security = fastmcp._request_state_security or RequestStateSecurity.ephemeral() - self.middleware.append( - cast( - "ServerMiddleware[LifespanResultT]", - RequestStateBoundary(security, default_audience=self.name), - ) - ) - @property def fastmcp(self) -> FastMCP: """Get the FastMCP instance.""" @@ -520,9 +273,13 @@ class LowLevelServer(_Server[LifespanResultT]): # ensure we use the FastMCP notification options if notification_options is None: notification_options = self.notification_options + merged = { + **self.fastmcp.experimental_capabilities, + **(experimental_capabilities or {}), + } return super().create_initialization_options( notification_options=notification_options, - experimental_capabilities=experimental_capabilities, + experimental_capabilities=merged or None, extensions=extensions, ) @@ -534,45 +291,25 @@ class LowLevelServer(_Server[LifespanResultT]): *, protocol_version: str | None = None, ) -> mcp_types.ServerCapabilities: - """Override to advertise registered extensions and the MCP Apps UI extension. + """Override to set capabilities.tasks as a first-class field per SEP-1686 + and advertise the MCP Apps UI extension. - ``ServerCapabilities.extensions`` is a real declared field in v2, so we - update it directly. The - `FastMCP(experimental_capabilities=...)` merge also lives here rather - than in `create_initialization_options`: the modern `server/discover` - handler calls this directly, without going through - `create_initialization_options` at all, so merging there only reached - the handshake-era `initialize` response and silently dropped - constructor-configured experimental capabilities from `discover`. + ``ServerCapabilities.tasks`` and ``ServerCapabilities.extensions`` are + real declared fields in v2, so we update them directly. """ - merged_experimental = { - **self.fastmcp.experimental_capabilities, - **(experimental_capabilities or {}), - } + from fastmcp.server.tasks.capabilities import get_task_capabilities + capabilities = super().get_capabilities( notification_options, - merged_experimental or None, + experimental_capabilities, extensions, protocol_version=protocol_version, ) - # Advertise every registered extension's settings under - # capabilities.extensions[identifier]. The hand-rolled UI splice stays - # for now (MCP Apps migrates onto the extension API in a later phase); - # the two coexist. Advertisement is unconditional — the SDK's pre-2026 - # version sieve strips capabilities.extensions on legacy eras, a known - # limitation (sdk-feedback #2). existing_extensions = capabilities.extensions or {} - registered_extensions = { - extension.identifier: extension.settings() - for extension in self.fastmcp._extensions.values() - } return capabilities.model_copy( update={ - "extensions": { - **existing_extensions, - UI_EXTENSION_ID: {}, - **registered_extensions, - }, + "tasks": get_task_capabilities(), + "extensions": {**existing_extensions, UI_EXTENSION_ID: {}}, } ) diff --git a/fastmcp_slim/fastmcp/server/middleware/authorization.py b/fastmcp_slim/fastmcp/server/middleware/authorization.py index 42f3cad81..2040778e3 100644 --- a/fastmcp_slim/fastmcp/server/middleware/authorization.py +++ b/fastmcp_slim/fastmcp/server/middleware/authorization.py @@ -29,10 +29,15 @@ from typing import Any import mcp_types as mt -from fastmcp.exceptions import AuthorizationError, InsufficientScopeError +from fastmcp.exceptions import AuthorizationError from fastmcp.prompts.base import Prompt, PromptResult from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.auth.authorization import ( + AuthCheck, + AuthContext, + run_auth_checks, +) from fastmcp.server.dependencies import get_access_token from fastmcp.server.middleware.middleware import ( CallNext, @@ -40,13 +45,6 @@ from fastmcp.server.middleware.middleware import ( MiddlewareContext, ) from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.authorization import ( - AuthCheck, - AuthContext, - run_auth_checks, - run_auth_checks_with_shortfall, - scope_requirements, -) from fastmcp.utilities.versions import VersionSpec logger = logging.getLogger(__name__) @@ -113,60 +111,6 @@ class AuthMiddleware(Middleware): def __init__(self, auth: AuthCheck | list[AuthCheck]) -> None: self.auth = auth - def _chain_shortfall( - self, - own_missing: list[str], - ctx: AuthContext, - server: Any, - ) -> list[str]: - """Widen this middleware's shortfall to cover the rest of the chain. - - Scope requirements are commonly split across several `AuthMiddleware` - instances (the tag-based configuration does exactly this). Raising as - soon as the first one finds a shortfall means the middleware further in - never runs, so a caller told only the outer scope would obtain it, retry, - and be denied for the next — the same non-convergent loop that reporting - a union within one middleware avoids. - - Sibling requirements are read without evaluating them: `scope_requirements` - is a pure comparison against the token and component. Only the layers the - request would actually reach next may contribute, so the walk starts after - this middleware and stops at the first one whose requirements cannot be - read — a layer holding an opaque check is an unverified gate, and anything - at or beyond it might be unreachable for reasons that have nothing to do - with scopes. Disclosing those requirements would leak what sits behind a - policy the request never passed. - - Layers *before* this one are already known to have passed, so they neither - block the walk nor contribute anything. This middleware's own shortfall - always counts: the request demonstrably reached it. - - The result is therefore complete when the reachable chain is scope-only, - and deliberately partial otherwise — never a disclosure past an - unevaluated gate. - """ - middleware = getattr(server, "middleware", None) - if not isinstance(middleware, list): - return own_missing - - position = next( - (i for i, mw in enumerate(middleware) if mw is self), - None, - ) - if position is None: - return own_missing - - missing = set(own_missing) - for mw in middleware[position + 1 :]: - if not isinstance(mw, AuthMiddleware): - continue - sibling = scope_requirements(mw.auth, ctx) - if sibling is None: - # An unverified gate. Nothing at or beyond it may contribute. - break - missing |= set(sibling) - return sorted(missing) - async def on_list_tools( self, context: MiddlewareContext[mt.ListToolsRequest], @@ -236,17 +180,7 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=tool) - authorized, missing = await run_auth_checks_with_shortfall(self.auth, ctx) - if not authorized: - if missing: - missing = self._chain_shortfall(missing, ctx, fastmcp.fastmcp) - raise InsufficientScopeError( - missing, - message=( - f"Authorization failed for tool '{tool_name}': " - f"insufficient scope (required: {', '.join(missing)})" - ), - ) + if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for tool '{tool_name}': insufficient permissions" ) @@ -324,17 +258,7 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=component) - authorized, missing = await run_auth_checks_with_shortfall(self.auth, ctx) - if not authorized: - if missing: - missing = self._chain_shortfall(missing, ctx, fastmcp.fastmcp) - raise InsufficientScopeError( - missing, - message=( - f"Authorization failed for resource '{uri}': " - f"insufficient scope (required: {', '.join(missing)})" - ), - ) + if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for resource '{uri}': insufficient permissions" ) @@ -436,17 +360,7 @@ class AuthMiddleware(Middleware): # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=prompt) - authorized, missing = await run_auth_checks_with_shortfall(self.auth, ctx) - if not authorized: - if missing: - missing = self._chain_shortfall(missing, ctx, fastmcp.fastmcp) - raise InsufficientScopeError( - missing, - message=( - f"Authorization failed for prompt '{prompt_name}': " - f"insufficient scope (required: {', '.join(missing)})" - ), - ) + if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for prompt '{prompt_name}': insufficient permissions" ) diff --git a/fastmcp_slim/fastmcp/server/middleware/caching.py b/fastmcp_slim/fastmcp/server/middleware/caching.py index a6db7eced..94c440e2c 100644 --- a/fastmcp_slim/fastmcp/server/middleware/caching.py +++ b/fastmcp_slim/fastmcp/server/middleware/caching.py @@ -1,7 +1,6 @@ """A middleware for response caching.""" import hashlib -import json from collections.abc import Sequence from logging import Logger from typing import Any, TypedDict @@ -19,48 +18,16 @@ from key_value.aio.wrappers.statistics.wrapper import ( from pydantic import Field from typing_extensions import NotRequired, Self, TypeVar, override -from fastmcp.prompts.base import ( - InputRequiredPromptResult, - Message, - Prompt, - PromptResult, -) -from fastmcp.resources.base import ( - InputRequiredResourceResult, - Resource, - ResourceContent, - ResourceResult, -) +from fastmcp.prompts.base import Message, Prompt, PromptResult +from fastmcp.resources.base import Resource, ResourceContent, ResourceResult from fastmcp.server.dependencies import get_access_token from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel logger: Logger = get_logger(name=__name__) - -def _is_continuation_leg(context: MiddlewareContext[Any]) -> bool: - """Whether this request is answering a previous round's ask (SEP-2322). - - A continuation must bypass the cache entirely. Cache keys are built from the - component's identity and arguments alone, so a continuation shares its key - with a fresh call: reading could serve a prior flow's final answer to this - leg, and writing would serve THIS flow's final answer to a later fresh call, - which would then never be asked the questions at all. - - Either signal marks a continuation. A state-only round (one that carried - `request_state` without asking anything) retries with `input_responses` - still `None`. - """ - fastmcp_ctx = context.fastmcp_context - if fastmcp_ctx is None: - return False - return ( - fastmcp_ctx.input_responses is not None or fastmcp_ctx.request_state is not None - ) - - # Constants ONE_HOUR_IN_SECONDS = 3600 FIVE_MINUTES_IN_SECONDS = 300 @@ -82,7 +49,7 @@ def _to_base_model(value: FastMCPBaseModel, model_type: type[BaseModelT]) -> Bas return model_type.model_validate(field_values) -class CacheableResourceContent(FastMCPBaseModel): +class CachableResourceContent(FastMCPBaseModel): """A wrapper for ResourceContent that can be cached.""" content: str | bytes @@ -90,10 +57,10 @@ class CacheableResourceContent(FastMCPBaseModel): meta: dict[str, Any] | None = None -class CacheableResourceResult(FastMCPBaseModel): +class CachableResourceResult(FastMCPBaseModel): """A wrapper for ResourceResult that can be cached.""" - contents: list[CacheableResourceContent] + contents: list[CachableResourceContent] meta: dict[str, Any] | None = None def get_size(self) -> int: @@ -103,7 +70,7 @@ class CacheableResourceResult(FastMCPBaseModel): def wrap(cls, value: ResourceResult) -> Self: return cls( contents=[ - CacheableResourceContent( + CachableResourceContent( content=item.content, mime_type=item.mime_type, meta=item.meta ) for item in value.contents @@ -123,7 +90,7 @@ class CacheableResourceResult(FastMCPBaseModel): ) -class CacheableToolResult(FastMCPBaseModel): +class CachableToolResult(FastMCPBaseModel): content: list[mcp_types.ContentBlock] structured_content: dict[str, Any] | None meta: dict[str, Any] | None @@ -147,7 +114,7 @@ class CacheableToolResult(FastMCPBaseModel): ) -class CacheableMessage(FastMCPBaseModel): +class CachableMessage(FastMCPBaseModel): """A wrapper for Message that can be cached.""" role: str @@ -159,10 +126,10 @@ class CacheableMessage(FastMCPBaseModel): ) -class CacheablePromptResult(FastMCPBaseModel): +class CachablePromptResult(FastMCPBaseModel): """A wrapper for PromptResult that can be cached.""" - messages: list[CacheableMessage] + messages: list[CachableMessage] description: str | None = None meta: dict[str, Any] | None = None @@ -173,7 +140,7 @@ class CacheablePromptResult(FastMCPBaseModel): def wrap(cls, value: PromptResult) -> Self: return cls( messages=[ - CacheableMessage(role=m.role, content=m.content) for m in value.messages + CachableMessage(role=m.role, content=m.content) for m in value.messages ], description=value.description, meta=value.meta, @@ -320,25 +287,23 @@ class ResponseCachingMiddleware(Middleware): default_collection="prompts/list", ) - self._read_resource_cache: PydanticAdapter[CacheableResourceResult] = ( + self._read_resource_cache: PydanticAdapter[CachableResourceResult] = ( PydanticAdapter( key_value=self._stats, - pydantic_model=CacheableResourceResult, + pydantic_model=CachableResourceResult, default_collection="resources/read", ) ) - self._get_prompt_cache: PydanticAdapter[CacheablePromptResult] = ( - PydanticAdapter( - key_value=self._stats, - pydantic_model=CacheablePromptResult, - default_collection="prompts/get", - ) + self._get_prompt_cache: PydanticAdapter[CachablePromptResult] = PydanticAdapter( + key_value=self._stats, + pydantic_model=CachablePromptResult, + default_collection="prompts/get", ) - self._call_tool_cache: PydanticAdapter[CacheableToolResult] = PydanticAdapter( + self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter( key_value=self._stats, - pydantic_model=CacheableToolResult, + pydantic_model=CachableToolResult, default_collection="tools/call", ) @@ -355,25 +320,21 @@ class ResponseCachingMiddleware(Middleware): cache_key: str = _get_auth_partition_key() - # an empty list is a cached result, not a miss: `get` returns None when the key is - # absent, so testing truthiness would re-list on every request for any caller whose - # filtered view is empty - cached_value = await self._list_tools_cache.get(key=cache_key) - if cached_value is not None: + if cached_value := await self._list_tools_cache.get(key=cache_key): return cached_value tools: Sequence[Tool] = await call_next(context) # Turn any subclass of Tool into a Tool - cacheable_tools = [_to_base_model(tool, Tool) for tool in tools] + cachable_tools = [_to_base_model(tool, Tool) for tool in tools] await self._list_tools_cache.put( key=cache_key, - value=cacheable_tools, + value=cachable_tools, ttl=self._list_tools_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) - return cacheable_tools + return cachable_tools @override async def on_list_resources( @@ -388,25 +349,23 @@ class ResponseCachingMiddleware(Middleware): cache_key: str = _get_auth_partition_key() - # an empty list is a cached result, not a miss (see on_list_tools) - cached_value = await self._list_resources_cache.get(key=cache_key) - if cached_value is not None: + if cached_value := await self._list_resources_cache.get(key=cache_key): return cached_value resources: Sequence[Resource] = await call_next(context) # Turn any subclass of Resource into a Resource - cacheable_resources = [ + cachable_resources = [ _to_base_model(resource, Resource) for resource in resources ] await self._list_resources_cache.put( key=cache_key, - value=cacheable_resources, + value=cachable_resources, ttl=self._list_resources_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) - return cacheable_resources + return cachable_resources @override async def on_list_prompts( @@ -421,23 +380,21 @@ class ResponseCachingMiddleware(Middleware): cache_key: str = _get_auth_partition_key() - # an empty list is a cached result, not a miss (see on_list_tools) - cached_value = await self._list_prompts_cache.get(key=cache_key) - if cached_value is not None: + if cached_value := await self._list_prompts_cache.get(key=cache_key): return cached_value prompts: Sequence[Prompt] = await call_next(context) # Turn any subclass of Prompt into a Prompt - cacheable_prompts = [_to_base_model(prompt, Prompt) for prompt in prompts] + cachable_prompts = [_to_base_model(prompt, Prompt) for prompt in prompts] await self._list_prompts_cache.put( key=cache_key, - value=cacheable_prompts, + value=cachable_prompts, ttl=self._list_prompts_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) - return cacheable_prompts + return cachable_prompts @override async def on_call_tool( @@ -454,9 +411,6 @@ class ResponseCachingMiddleware(Middleware): ) is False or not self._matches_tool_cache_settings(tool_name=tool_name): return await call_next(context) - if _is_continuation_leg(context): - return await call_next(context) - cache_key: str = _make_call_tool_cache_key( msg=context.message, auth_key=_get_auth_partition_key() ) @@ -465,43 +419,17 @@ class ResponseCachingMiddleware(Middleware): return cached_value.unwrap() tool_result: ToolResult = await call_next(context) - - # Never cache a multi-round-trip ask (SEP-2322). An - # InputRequiredToolResult is a request for client input on this leg, not - # a stable answer; caching it would replay a stale question to later - # callers and bypass the tool's own per-round logic. Return it straight - # through without storing. - if isinstance(tool_result, InputRequiredToolResult): - return tool_result - - # A task-augmented call returns a CreateTaskResult (the tasks extension) - # up through this middleware — an acknowledgement that the work was - # enqueued, not a cacheable answer, and without a ToolResult's - # content/structured_content. Pass any non-ToolResult straight through - # rather than crash wrapping it (the crash would fire after the task is - # already enqueued, so a client retry could duplicate side effects). - if not isinstance(tool_result, ToolResult): - return tool_result - - # Never cache an error result. A tool that reports failure by returning - # is_error=True is describing this attempt, not a stable answer — the - # upstream 503 or bad gateway it is reporting is exactly the kind of - # thing that clears on retry. Caching it would pin the failure in place - # for the full TTL and stop the tool from ever being retried. - if tool_result.is_error: - return tool_result - - cacheable_tool_result: CacheableToolResult = CacheableToolResult.wrap( + cachable_tool_result: CachableToolResult = CachableToolResult.wrap( value=tool_result ) await self._call_tool_cache.put( key=cache_key, - value=cacheable_tool_result, + value=cachable_tool_result, ttl=self._call_tool_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) - return cacheable_tool_result.unwrap() + return cachable_tool_result.unwrap() @override async def on_read_resource( @@ -514,27 +442,16 @@ class ResponseCachingMiddleware(Middleware): if self._read_resource_settings.get("enabled") is False: return await call_next(context) - if _is_continuation_leg(context): - return await call_next(context) - cache_key: str = _make_read_resource_cache_key( msg=context.message, auth_key=_get_auth_partition_key() ) - cached_value: CacheableResourceResult | None + cached_value: CachableResourceResult | None if cached_value := await self._read_resource_cache.get(key=cache_key): return cached_value.unwrap() value: ResourceResult = await call_next(context) - - # Never cache a multi-round-trip ask (SEP-2322). An - # InputRequiredResourceResult is a request for client input on this leg, - # not a stable answer, and it carries no contents — wrapping it would - # cache an empty read and the client would never see the question. - if isinstance(value, InputRequiredResourceResult): - return value - - cached_value = CacheableResourceResult.wrap(value) + cached_value = CachableResourceResult.wrap(value) await self._read_resource_cache.put( key=cache_key, @@ -555,9 +472,6 @@ class ResponseCachingMiddleware(Middleware): if self._get_prompt_settings.get("enabled") is False: return await call_next(context) - if _is_continuation_leg(context): - return await call_next(context) - cache_key: str = _make_get_prompt_cache_key( msg=context.message, auth_key=_get_auth_partition_key() ) @@ -566,15 +480,7 @@ class ResponseCachingMiddleware(Middleware): return cached_value.unwrap() value: PromptResult = await call_next(context) - - # Never cache a multi-round-trip ask (SEP-2322). An - # InputRequiredPromptResult is a request for client input on this leg, - # not a stable answer, and it carries no messages — wrapping it would - # cache an empty prompt and the client would never see the question. - if isinstance(value, InputRequiredPromptResult): - return value - - cached_value = CacheablePromptResult.wrap(value) + cached_value = CachablePromptResult.wrap(value) await self._get_prompt_cache.put( key=cache_key, @@ -610,19 +516,13 @@ class ResponseCachingMiddleware(Middleware): def _get_arguments_str(arguments: dict[str, Any] | None) -> str: - """Get a canonical string representation of the arguments.""" + """Get a string representation of the arguments.""" if arguments is None: return "null" try: - return json.dumps( - pydantic_core.to_jsonable_python(arguments, fallback=str), - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - default=str, - ) + return pydantic_core.to_json(value=arguments, fallback=str).decode() except TypeError: return repr(arguments) diff --git a/fastmcp_slim/fastmcp/server/middleware/middleware.py b/fastmcp_slim/fastmcp/server/middleware/middleware.py index 87a1b9914..d2bada05e 100644 --- a/fastmcp_slim/fastmcp/server/middleware/middleware.py +++ b/fastmcp_slim/fastmcp/server/middleware/middleware.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging from collections.abc import Awaitable, Callable, Sequence -from contextvars import ContextVar from dataclasses import dataclass, field, replace from datetime import datetime, timezone from typing import ( @@ -33,53 +32,6 @@ __all__ = [ logger = logging.getLogger(__name__) -MiddlewarePhase = Literal["all", "outer", "typed"] -"""Which slice of a middleware's hooks to run in a single dispatch pass. - -- ``"all"`` runs the whole chain in one pass (``on_message`` -> ``on_request`` / - ``on_notification`` -> the typed per-method hook). This is what the interior - component methods (``call_tool``, ``list_tools``, ...) run for the methods they - serve, and what the ``initialize`` request runs at the dispatch root. -- ``"outer"`` runs only ``on_message`` and ``on_request``/``on_notification``. - The root dispatch (in the SDK's middleware layer) runs this pass for the messages the interior never - dispatches (notifications, cancellations, unroutable/non-component requests, - and pre-handler failures), so ``on_message`` observes *every* inbound message - without double-firing for the component methods the interior already covers. -- ``"typed"`` runs only the per-method hook. Reserved for a future full split; - no current dispatch path uses it. -""" - - -_interior_dispatched: ContextVar[bool] = ContextVar( - "fastmcp_interior_dispatched", default=False -) -"""Set to True by an interior component dispatch when it runs its middleware chain. - -The root dispatch reads this to tell whether the FastMCP middleware -chain already fired *inside* the wire request (so ``on_message``/``on_request`` -were observed there — including any tool exception, exactly where the built-in -error/logging/timing middleware expect them). It is only consulted for the -component methods: if such a request fails *before* the interior runs (malformed -params, routing), the flag stays False and the root dispatch observes the failure itself. -""" - - -def mark_interior_dispatched() -> None: - """Record that an interior component middleware chain ran for this message.""" - _interior_dispatched.set(True) - - -_dispatch_phase: ContextVar[MiddlewarePhase] = ContextVar( - "fastmcp_dispatch_phase", default="all" -) -"""The dispatch phase for the middleware chain currently running. - -Set by ``FastMCP._run_middleware`` around each chain execution and read by -``Middleware.__call__``, so the phase never appears in the middleware call -signature — user middleware overriding the documented -``__call__(context, call_next)`` keeps working unchanged. -""" - T = TypeVar("T", default=Any) R = TypeVar("R", covariant=True, default=Any) @@ -141,63 +93,47 @@ class Middleware: context: MiddlewareContext[T], call_next: CallNext[T, Any], ) -> Any: - """Main entry point that orchestrates the pipeline. - - The dispatch phase — which slice of the hooks runs (see - ``MiddlewarePhase``) — is read from ``_dispatch_phase`` rather than - passed as an argument, so middleware that overrides this method with the - documented ``(context, call_next)`` signature keeps working unchanged. - Such an override runs once per message regardless of phase, which - matches its pre-existing behavior. - """ + """Main entry point that orchestrates the pipeline.""" handler_chain = await self._dispatch_handler( context, call_next=call_next, - phase=_dispatch_phase.get(), ) return await handler_chain(context) async def _dispatch_handler( - self, - context: MiddlewareContext[Any], - call_next: CallNext[Any, Any], - phase: MiddlewarePhase = "all", + self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] ) -> CallNext[Any, Any]: - """Builds a chain of handlers for a given message and dispatch phase.""" + """Builds a chain of handlers for a given message.""" handler = call_next - if phase in ("all", "typed"): - match context.method: - case "initialize": - handler = make_handler_wrapper(self.on_initialize, handler) - case "server/discover": - handler = make_handler_wrapper(self.on_discover, handler) - case "tools/call": - handler = make_handler_wrapper(self.on_call_tool, handler) - case "resources/read": - handler = make_handler_wrapper(self.on_read_resource, handler) - case "prompts/get": - handler = make_handler_wrapper(self.on_get_prompt, handler) - case "tools/list": - handler = make_handler_wrapper(self.on_list_tools, handler) - case "resources/list": - handler = make_handler_wrapper(self.on_list_resources, handler) - case "resources/templates/list": - handler = make_handler_wrapper( - self.on_list_resource_templates, - handler, - ) - case "prompts/list": - handler = make_handler_wrapper(self.on_list_prompts, handler) + match context.method: + case "initialize": + handler = make_handler_wrapper(self.on_initialize, handler) + case "tools/call": + handler = make_handler_wrapper(self.on_call_tool, handler) + case "resources/read": + handler = make_handler_wrapper(self.on_read_resource, handler) + case "prompts/get": + handler = make_handler_wrapper(self.on_get_prompt, handler) + case "tools/list": + handler = make_handler_wrapper(self.on_list_tools, handler) + case "resources/list": + handler = make_handler_wrapper(self.on_list_resources, handler) + case "resources/templates/list": + handler = make_handler_wrapper( + self.on_list_resource_templates, + handler, + ) + case "prompts/list": + handler = make_handler_wrapper(self.on_list_prompts, handler) - if phase in ("all", "outer"): - match context.type: - case "request": - handler = make_handler_wrapper(self.on_request, handler) - case "notification": - handler = make_handler_wrapper(self.on_notification, handler) + match context.type: + case "request": + handler = make_handler_wrapper(self.on_request, handler) + case "notification": + handler = make_handler_wrapper(self.on_notification, handler) - handler = make_handler_wrapper(self.on_message, handler) + handler = make_handler_wrapper(self.on_message, handler) return handler @@ -229,13 +165,6 @@ class Middleware: ) -> mt.InitializeResult | None: return await call_next(context) - async def on_discover( - self, - context: MiddlewareContext[mt.DiscoverRequest], - call_next: CallNext[mt.DiscoverRequest, mt.DiscoverResult | dict[str, Any]], - ) -> mt.DiscoverResult | dict[str, Any]: - return await call_next(context) - async def on_call_tool( self, context: MiddlewareContext[mt.CallToolRequestParams], diff --git a/fastmcp_slim/fastmcp/server/middleware/ping.py b/fastmcp_slim/fastmcp/server/middleware/ping.py index 02329ca60..e81ccc377 100644 --- a/fastmcp_slim/fastmcp/server/middleware/ping.py +++ b/fastmcp_slim/fastmcp/server/middleware/ping.py @@ -71,15 +71,6 @@ class PingMiddleware(Middleware): ping_task.cancel() with contextlib.suppress(asyncio.CancelledError): await ping_task - # `ping_task` may be cancelled before its first - # scheduler turn (a connection can be built and torn - # down within a single request on the modern, - # per-request `Connection` path), in which case its - # body - and the `finally` in `_ping_loop` that would - # otherwise discard this entry - never runs. Discard - # unconditionally here so a connection that closes - # before the loop starts doesn't leak its entry. - self._active_sessions.discard(connection_id) connection.exit_stack.push_async_callback(_cancel_ping) diff --git a/fastmcp_slim/fastmcp/server/middleware/response_limiting.py b/fastmcp_slim/fastmcp/server/middleware/response_limiting.py index c62ec0243..32f08c056 100644 --- a/fastmcp_slim/fastmcp/server/middleware/response_limiting.py +++ b/fastmcp_slim/fastmcp/server/middleware/response_limiting.py @@ -9,7 +9,7 @@ import mcp_types as mt import pydantic_core from mcp_types import TextContent -from fastmcp.tools.base import InputRequiredToolResult, ToolResult +from fastmcp.tools.base import ToolResult from .middleware import CallNext, Middleware, MiddlewareContext @@ -110,20 +110,6 @@ class ResponseLimitingMiddleware(Middleware): """Intercept tool calls and limit response size.""" result = await call_next(context) - # A multi-round-trip ask (SEP-2322) carries no tool content to measure, - # and truncating it would collapse the InputRequiredToolResult into a - # plain ToolResult — the wire handler would then serialize the ask as - # content instead of returning it as an input-required result. Pass it - # through untouched. - if isinstance(result, InputRequiredToolResult): - return result - - # A task-augmented call returns a CreateTaskResult (the tasks extension) - # up through this middleware — a small acknowledgement with no tool - # content to measure or truncate. Pass any non-ToolResult through. - if not isinstance(result, ToolResult): - return result - # Check if we should limit this tool if self.tools is not None and context.message.name not in self.tools: return result diff --git a/fastmcp_slim/fastmcp/server/mixins/lifespan.py b/fastmcp_slim/fastmcp/server/mixins/lifespan.py index 79699da00..77374d3f7 100644 --- a/fastmcp_slim/fastmcp/server/mixins/lifespan.py +++ b/fastmcp_slim/fastmcp/server/mixins/lifespan.py @@ -1,16 +1,18 @@ -"""Lifespan infrastructure for FastMCP Server.""" +"""Lifespan and Docket task infrastructure for FastMCP Server.""" from __future__ import annotations +import asyncio import weakref from collections.abc import AsyncIterator -from contextlib import AsyncExitStack, asynccontextmanager +from contextlib import AsyncExitStack, asynccontextmanager, suppress from contextvars import ContextVar -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import anyio from uncalled_for import SharedContext +import fastmcp from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: @@ -23,129 +25,171 @@ logger = get_logger(__name__) # Set True by `FastMCPProvider.lifespan` immediately before it enters the # wrapped (mounted) server's `_lifespan_manager`, and reset on exit. The -# mounted server's `_shared_context_lifespan` reads this and becomes a no-op so -# that SharedContext and the server ContextVar are not re-initialized — there's -# one set per runtime tree, owned by the root. Extension lifespans (e.g. the -# tasks extension's Docket/Worker) defer to the root the same way. +# mounted server's `_docket_lifespan` reads this and becomes a no-op so that +# Docket / Worker / SharedContext are not re-initialized — there's one set +# per runtime tree, owned by the root. # # Independent servers entered as siblings (e.g. via `AsyncExitStack` in the # same async context) are NOT in a parent/child relationship; the flag is not -# set in that case, so each independently establishes its own server context. +# set in that case, so each independently establishes its own Docket and +# server context. _lifespan_root_active: ContextVar[bool] = ContextVar( "fastmcp_lifespan_root_active", default=False ) class LifespanMixin: - """Mixin providing lifespan infrastructure for FastMCP.""" + """Mixin providing lifespan and Docket task infrastructure for FastMCP.""" @property def docket(self: FastMCP) -> Docket | None: - """The Docket instance owned by this server, if the tasks extension is active. + """The Docket instance owned by this server. - Returns the Docket that the tasks extension initialized as the root of a - runtime tree, or None when no task backend is running. Mounted children do - not own their own Docket — they share the root's via ``_current_docket`` - ContextVar inheritance — so accessing ``.docket`` on a mounted child - returns None even while its tasks run on the root's Docket. + Returns the Docket that this server initialized as the root of a + runtime tree. Mounted children do not own their own Docket — they + share the root's via ``_current_docket`` ContextVar inheritance — + so accessing ``.docket`` on a mounted child returns None even while + its tasks run on the root's Docket. For "the Docket in scope right + now," prefer reading ``_current_docket`` directly or use the + ``CurrentDocket`` dependency injection. """ return self._docket @asynccontextmanager - async def _shared_context_lifespan(self: FastMCP) -> AsyncIterator[None]: - """Set up the process-level ``SharedContext`` and server ContextVar. + async def _docket_lifespan(self: FastMCP) -> AsyncIterator[None]: + """Manage Docket instance and Worker for background task execution. - ``SharedContext`` backs app-scoped ``Shared()`` dependencies and is - process-level, not server-level: only the first server in a runtime tree - establishes it. Mounted children entered via ``FastMCPProvider.lifespan`` - see ``_lifespan_root_active=True`` (set by the provider before delegating - to ``_lifespan_manager``) and become no-ops, sharing the root's context - via ContextVars. + Docket is process-level, not server-level: only the first server in a + runtime tree starts Docket and the Worker. Mounted children entered + via ``FastMCPProvider.lifespan`` see ``_lifespan_root_active=True`` + (set by the provider before delegating to ``_lifespan_manager``) and + become no-ops, sharing the root's Docket via ``_current_docket``. Independent servers entered as siblings — for example two unrelated - ``FastMCP`` instances each entered through ``AsyncExitStack`` in the same - async context — are not in a parent/child relationship; no provider has - set the flag for them, so each runs the full root setup. + ``FastMCP`` instances each entered through ``AsyncExitStack`` in the + same async context — are not in a parent/child relationship; no + provider has set the flag for them, so each runs the full root setup. + + Docket infrastructure is only initialized at the root if: + 1. pydocket is installed (fastmcp[tasks] extra) + 2. There are task-enabled components (task_config.mode != 'forbidden') + + Users with pydocket installed but no task-enabled components won't spin + up Docket / Worker infrastructure even at the root. """ + # Nested entry: a parent in this runtime tree already owns Docket and + # SharedContext (the FastMCPProvider that mounted us set the flag). + # Stay out of their way and inherit via ContextVars. if _lifespan_root_active.get(): yield return - from fastmcp.server.dependencies import _current_server + async with self._docket_lifespan_root(): + yield + + @asynccontextmanager + async def _docket_lifespan_root(self: FastMCP) -> AsyncIterator[None]: + """Root-only Docket lifecycle. See _docket_lifespan for the dispatch.""" + from fastmcp.server.dependencies import _current_server, is_docket_available # Set FastMCP server in ContextVar so CurrentFastMCP can access it # (use weakref to avoid reference cycles) server_token = _current_server.set(weakref.ref(self)) + try: - async with SharedContext(): + # If docket is not available, skip task infrastructure but still + # set up SharedContext so Shared() dependencies work. + if not is_docket_available(): + async with SharedContext(): + self._capture_shared_context() + yield + return + + # Collect task-enabled components at startup with all transforms applied. + # Components must be available now to be registered with Docket workers; + # dynamically added components after startup won't be registered. + try: + task_components = list(await self.get_tasks()) + except Exception as e: + logger.warning(f"Failed to get tasks: {e}") + if fastmcp.settings.mounted_components_raise_on_load_error: + raise + task_components = [] + + # If no task-enabled components, skip Docket infrastructure but still + # set up SharedContext so Shared() dependencies work. + if not task_components: + async with SharedContext(): + self._capture_shared_context() + yield + return + + # Docket is available AND there are task-enabled components + from docket import Depends, Docket, Worker + + from fastmcp import settings + from fastmcp.server.dependencies import ( + _current_docket, + _current_worker, + ) + from fastmcp.server.tasks.context import restore_task_snapshot + + # Create Docket instance using configured name and URL + async with ( + SharedContext(), + Docket( + name=settings.docket.name, + url=settings.docket.url, + ) as docket, + ): self._capture_shared_context() - yield + self._docket = docket + + # Register task-enabled components with Docket + for component in task_components: + component.register_with_docket(docket) + + docket_token = _current_docket.set(docket) + try: + # Build worker kwargs from settings + worker_kwargs: dict[str, Any] = { + "concurrency": settings.docket.concurrency, + "redelivery_timeout": settings.docket.redelivery_timeout, + "reconnection_delay": settings.docket.reconnection_delay, + "minimum_check_interval": settings.docket.minimum_check_interval, + } + if settings.docket.worker_name: + worker_kwargs["name"] = settings.docket.worker_name + + # Create and start Worker. The restore_task_snapshot + # worker-level dependency runs before every task so the + # per-task snapshot ContextVar is populated before user + # code or task-scoped dependencies observe it. + async with Worker( + docket, + dependencies=[Depends(restore_task_snapshot)], + **worker_kwargs, + ) as worker: + self._worker = worker + worker_token = _current_worker.set(worker) + try: + worker_task = asyncio.create_task(worker.run_forever()) + try: + yield + finally: + worker_task.cancel() + with suppress(asyncio.CancelledError): + await worker_task + finally: + _current_worker.reset(worker_token) + self._worker = None + finally: + _current_docket.reset(docket_token) + self._docket = None finally: + # Reset server ContextVar _current_server.reset(server_token) - @asynccontextmanager - async def _extensions_lifespan(self: FastMCP) -> AsyncIterator[None]: - """Enter each registered extension's lifespan, exit them on shutdown. - - Extension lifespans are entered once per runtime tree, at the root. A - mounted child sees ``_lifespan_root_active`` set by its - ``FastMCPProvider`` and defers to the root: an extension whose lifespan - starts shared infrastructure (a task-queue backend and worker, say) is - therefore owned by the tree root, and mounted children reach it through - the same context rather than starting a second copy. - - Extensions are entered in registration order; the ``AsyncExitStack`` - exits them in reverse on teardown. - """ - if _lifespan_root_active.get() or not self._extensions: - yield - return - - async with AsyncExitStack() as stack: - for extension in self._extensions.values(): - await stack.enter_async_context(extension.lifespan()) - yield - - async def _validate_task_extension_registered(self: FastMCP) -> None: - """Fail loudly if a task-enabled tool has no tasks extension registered. - - `task=True` on a tool is only an intent declaration; the engine that runs - it lives in the `fastmcp-tasks` package and is installed by registering a - `ServerExtension` whose identifier is `TASKS_EXTENSION_ID` - (`mcp.add_extension(...)`). A task-configured tool serving without that - extension would silently never run as a task — a correctness bug — so we - raise at serve time instead. - """ - from fastmcp.utilities.tasks import TASKS_EXTENSION_ID - - # A mounted child defers to the root, which owns the extension and whose - # aggregated get_tasks() already covers this child's task tools — the - # same root-deferral the extension lifespan uses. Validating here would - # fail a child that legitimately relies on the root's registration. - if _lifespan_root_active.get(): - return - - if TASKS_EXTENSION_ID in self._extensions: - return - - candidates = list(await self.get_tasks()) - - # ``get_tasks()`` applies server-level transforms, which can inject - # non-task tools (e.g. ResourcesAsTools' synthetic list/read tools) into - # the result, so re-filter by the actual task config here — mirroring the - # guard the old per-component docket registration applied. - task_components = [c for c in candidates if c.task_config.supports_tasks()] - if not task_components: - return - - names = ", ".join(sorted(c.name for c in task_components)) - raise RuntimeError( - f"Task-enabled tools ({names}) require the tasks extension, but no " - f"extension with identifier {TASKS_EXTENSION_ID!r} is registered. " - "Install it with `pip install 'fastmcp[tasks]'` and register it via " - "`mcp.add_extension(TasksExtension(...))`." - ) - def _capture_shared_context(self: FastMCP) -> None: """Snapshot the live ``SharedContext`` ContextVar values. @@ -193,8 +237,7 @@ class LifespanMixin: stack = AsyncExitStack() try: user_lifespan_result = await stack.enter_async_context(self._lifespan(self)) - await stack.enter_async_context(self._shared_context_lifespan()) - await stack.enter_async_context(self._extensions_lifespan()) + await stack.enter_async_context(self._docket_lifespan()) self._lifespan_result = user_lifespan_result self._lifespan_result_set = True @@ -203,8 +246,6 @@ class LifespanMixin: for provider in self.providers: await stack.enter_async_context(provider.lifespan()) - await self._validate_task_extension_registered() - self._started.set() try: yield @@ -220,3 +261,74 @@ class LifespanMixin: if self._lifespan_ref_count == 0: self._lifespan_result_set = False self._lifespan_result = None + + def _setup_task_protocol_handlers(self: FastMCP) -> None: + """Register SEP-1686 task protocol handlers with SDK. + + Only registers handlers if docket is installed. Without docket, + task protocol requests will return "method not found" errors. + """ + from fastmcp.server.dependencies import is_docket_available + + if not is_docket_available(): + return + + from mcp.server.context import ServerRequestContext + from mcp_types import ( + CancelTaskRequestParams, + GetTaskPayloadRequestParams, + GetTaskRequestParams, + PaginatedRequestParams, + ) + + from fastmcp.server.dependencies import bind_request_context + from fastmcp.server.tasks.requests import ( + tasks_cancel_handler, + tasks_get_handler, + tasks_list_handler, + tasks_result_handler, + ) + + # v2 handlers take (ctx, params) and return the bare result model. + + async def handle_get_task( + ctx: ServerRequestContext, params: GetTaskRequestParams + ) -> Any: + with bind_request_context(ctx): + p = params.model_dump(by_alias=True, exclude_none=True) + return await tasks_get_handler(self, p) + + async def handle_get_task_result( + ctx: ServerRequestContext, params: GetTaskPayloadRequestParams + ) -> Any: + with bind_request_context(ctx): + p = params.model_dump(by_alias=True, exclude_none=True) + return await tasks_result_handler(self, p) + + async def handle_list_tasks( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> Any: + with bind_request_context(ctx): + p = ( + params.model_dump(by_alias=True, exclude_none=True) + if params + else {} + ) + return await tasks_list_handler(self, p) + + async def handle_cancel_task( + ctx: ServerRequestContext, params: CancelTaskRequestParams + ) -> Any: + with bind_request_context(ctx): + p = params.model_dump(by_alias=True, exclude_none=True) + return await tasks_cancel_handler(self, p) + + s = self._mcp_server + s.add_request_handler("tasks/get", GetTaskRequestParams, handle_get_task) + s.add_request_handler( + "tasks/result", GetTaskPayloadRequestParams, handle_get_task_result + ) + s.add_request_handler("tasks/list", PaginatedRequestParams, handle_list_tasks) + s.add_request_handler( + "tasks/cancel", CancelTaskRequestParams, handle_cancel_task + ) diff --git a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py index 5e96e37dc..74e59ae8f 100644 --- a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py +++ b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py @@ -2,9 +2,8 @@ from __future__ import annotations -import inspect from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar import mcp_types from mcp.server.context import ServerRequestContext @@ -12,15 +11,12 @@ from mcp.shared.exceptions import MCPError from mcp_types import ( INVALID_PARAMS, CallToolRequestParams, - CompleteRequestParams, EmptyResult, GetPromptRequestParams, PaginatedRequestParams, ReadResourceRequestParams, SetLevelRequestParams, ) -from mcp_types.version import MODERN_PROTOCOL_VERSIONS -from pydantic import BaseModel from fastmcp.exceptions import ( DisabledError, @@ -28,15 +24,8 @@ from fastmcp.exceptions import ( NotFoundError, to_mcp_error, ) -from fastmcp.prompts.base import InputRequiredPromptResult -from fastmcp.resources.base import InputRequiredResourceResult -from fastmcp.server.completions import CompletionValues, normalize_completion from fastmcp.server.dependencies import bind_request_context, extract_version_spec -from fastmcp.tools.base import InputRequiredToolResult, ToolResult -from fastmcp.utilities.async_utils import ( - call_sync_fn_in_threadpool, - is_coroutine_function, -) +from fastmcp.server.tasks.config import TaskMeta from fastmcp.utilities.logging import get_logger from fastmcp.utilities.pagination import paginate_sequence from fastmcp.utilities.versions import VersionSpec, dedupe_with_versions @@ -129,6 +118,9 @@ class MCPOperationsMixin: "logging/setLevel", SetLevelRequestParams, self._on_set_logging_level ) + # Register SEP-1686 task protocol handlers + self._setup_task_protocol_handlers() + async def _on_list_tools( self: FastMCP, ctx: ServerRequestContext, @@ -219,19 +211,12 @@ class MCPOperationsMixin: self: FastMCP, ctx: ServerRequestContext, params: CallToolRequestParams, - ) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult | BaseModel: + ) -> mcp_types.CallToolResult | mcp_types.CreateTaskResult: """Handle MCP 'tools/call' requests. - A guard tool (SEP-2322 multi-round-trip) requests client input by - returning an ``InputRequiredResult`` from its body; the run machinery - wraps that in an ``InputRequiredToolResult`` (a ``ToolResult`` - subclass), which flows back through the middleware chain as an ordinary - result. Here we unwrap it and hand the raw ``InputRequiredResult`` to - the runner so it reaches the wire as ``resultType: "input_required"`` - (the request-state boundary seals its ``requestState`` on egress). This - result shape only exists at 2026-07-28; on an earlier connection the - runner cannot serialize it, so we reject with a clear era error rather - than let it fail as a generic "invalid result". + Task metadata is a first-class params field (``params.task``); its + presence triggers backgrounding. The tool's ``_run()`` handles the + backgrounding decision so middleware runs before Docket. """ with bind_request_context(ctx): key = params.name @@ -241,9 +226,14 @@ class MCPOperationsMixin: ) version = _version_from_ctx(ctx) + task_meta = ( + TaskMeta(ttl=params.task.ttl) if params.task is not None else None + ) try: - result = await self.call_tool(key, arguments, version=version) + result = await self.call_tool( + key, arguments, version=version, task_meta=task_meta + ) except (DisabledError, NotFoundError): # Unknown/disabled tool: return an error result (matching the # v1 SDK's call_tool behavior) so the client surfaces a @@ -266,39 +256,22 @@ class MCPOperationsMixin: is_error=True, ) - if not isinstance(result, ToolResult): - # An extension's tools/call interceptor produced a non-ToolResult - # wire result — the tasks extension's CreateTaskResult when it ran - # the call as a task. Core does not interpret extension result - # shapes; hand it straight to the runner, which serializes it for - # the negotiated protocol version. + if isinstance(result, mcp_types.CreateTaskResult): return result - - if isinstance(result, InputRequiredToolResult): - # A guard tool requested client input (SEP-2322). The - # multi-round-trip result type only exists at 2026-07-28; on an - # earlier connection the runner cannot serialize it, so name the - # era problem instead of failing as a generic "invalid result". - if ctx.protocol_version not in MODERN_PROTOCOL_VERSIONS: - raise MCPError( - code=INVALID_PARAMS, - message=( - f"Tool {key!r} returned an InputRequiredResult to request " - "client input, but the multi-round-trip result type " - "(SEP-2322) only exists at MCP 2026-07-28; this connection " - f"negotiated {ctx.protocol_version!r}. Use ctx.elicit() for " - "server-initiated input on handshake-era connections." - ), - ) - return result.input_required return _normalize_call_tool_result(result.to_mcp_result()) async def _on_read_resource( self: FastMCP, ctx: ServerRequestContext, params: ReadResourceRequestParams, - ) -> mcp_types.ReadResourceResult | mcp_types.InputRequiredResult: - """Handle MCP 'resources/read' requests.""" + ) -> mcp_types.ReadResourceResult | mcp_types.CreateTaskResult: + """Handle MCP 'resources/read' requests. + + Note: ``ReadResourceRequestParams`` has no ``task`` field in this SDK + version, so resource task submission over the wire is not expressible; + ``task_meta`` is always None here. The CreateTaskResult return branch is + retained harmlessly pending an upstream ``task`` field on these params. + """ with bind_request_context(ctx): uri = params.uri logger.debug(f"[{self.name}] Handler called: read_resource %s", uri) @@ -308,49 +281,25 @@ class MCPOperationsMixin: try: result = await self.read_resource(str(uri), version=version) except (DisabledError, NotFoundError) as e: - # SEP-2164: echo the requested URI in `data` so a client that - # pipelined several reads can tell which one is missing. - raise MCPError( - code=INVALID_PARAMS, - message=f"Resource not found: {str(uri)!r}", - data={"uri": str(uri)}, + raise to_mcp_error( + NotFoundError(f"Resource not found: {str(uri)!r}") ) from e - except FastMCPError as e: - # Resource-visible errors (ResourceError, ValidationError, ...) - # must reach the wire as an MCPError. Resources have no - # error-result shape the way tools do, so the equivalent of - # _on_call_tool's error result is a translated MCPError: at - # 2026-07-28 the runner only preserves MCPError/ValidationError - # messages and masks anything else as "Internal server error", - # which would hide a legitimate client-input error. Masking - # already happened inside read_resource. - raise to_mcp_error(e) from e - - if isinstance(result, InputRequiredResourceResult): - # The resource requested client input (SEP-2322). As with tools - # and prompts, the multi-round-trip result type only exists at - # 2026-07-28, so name the era problem on an older connection - # rather than failing as a generic "invalid result". - if ctx.protocol_version not in MODERN_PROTOCOL_VERSIONS: - raise MCPError( - code=INVALID_PARAMS, - message=( - f"Resource {str(uri)!r} returned an InputRequiredResult " - "to request client input, but the multi-round-trip " - "result type (SEP-2322) only exists at MCP 2026-07-28; " - f"this connection negotiated {ctx.protocol_version!r}." - ), - ) - return result.input_required + if isinstance(result, mcp_types.CreateTaskResult): + return result return result.to_mcp_result(uri) async def _on_get_prompt( self: FastMCP, ctx: ServerRequestContext, params: GetPromptRequestParams, - ) -> mcp_types.GetPromptResult | mcp_types.InputRequiredResult: - """Handle MCP 'prompts/get' requests.""" + ) -> mcp_types.GetPromptResult | mcp_types.CreateTaskResult: + """Handle MCP 'prompts/get' requests. + + Note: ``GetPromptRequestParams`` has no ``task`` field in this SDK + version, so prompt task submission over the wire is not expressible; + ``task_meta`` is always None here. + """ with bind_request_context(ctx): name = params.name arguments = params.arguments @@ -366,31 +315,9 @@ class MCPOperationsMixin: result = await self.render_prompt(name, arguments, version=version) except (DisabledError, NotFoundError) as e: raise to_mcp_error(NotFoundError(f"Unknown prompt: {name!r}")) from e - except FastMCPError as e: - # Prompt-visible errors (PromptError, ValidationError, ...) must - # reach the wire as an MCPError for the same reason as - # resources: at 2026-07-28 anything that is not an - # MCPError/ValidationError is masked as "Internal server error". - # Masking already happened inside render_prompt. - raise to_mcp_error(e) from e - - if isinstance(result, InputRequiredPromptResult): - # The prompt requested client input (SEP-2322). As with tools, - # the multi-round-trip result type only exists at 2026-07-28, so - # name the era problem on an older connection rather than - # failing as a generic "invalid result". - if ctx.protocol_version not in MODERN_PROTOCOL_VERSIONS: - raise MCPError( - code=INVALID_PARAMS, - message=( - f"Prompt {name!r} returned an InputRequiredResult to " - "request client input, but the multi-round-trip result " - "type (SEP-2322) only exists at MCP 2026-07-28; this " - f"connection negotiated {ctx.protocol_version!r}." - ), - ) - return result.input_required + if isinstance(result, mcp_types.CreateTaskResult): + return result return result.to_mcp_prompt_result() async def _on_set_logging_level( @@ -413,54 +340,3 @@ class MCPOperationsMixin: session_id = _log_level_session_key(rc.session) self._client_log_levels[session_id] = params.level return EmptyResult() - - async def _on_complete( - self: FastMCP, - ctx: ServerRequestContext, - params: CompleteRequestParams, - ) -> mcp_types.CompleteResult: - """Handle MCP 'completion/complete' requests. - - Routes to the server's registered completion handler (set via - ``@mcp.completion``). The handler switches on the reference and argument - and returns candidate values. A handler that does not recognize the - reference/argument returns ``None`` or an empty sequence, which becomes - an empty completion rather than an error — an unknown reference is not a - protocol failure. This handler is registered on the low-level server - only once a completion handler exists, so the completions capability is - declared exactly when the server can answer. - """ - with bind_request_context(ctx): - logger.debug(f"[{self.name}] Handler called: complete %s", params.ref) - handler = self._completion_handler - if handler is None: - return mcp_types.CompleteResult( - completion=mcp_types.Completion(values=[]) - ) - - if is_coroutine_function(handler): - raw = handler(params.ref, params.argument, params.context) - else: - # A sync handler may perform blocking work (a database lookup, - # say); run it in a threadpool so it does not stall the event - # loop, matching how sync tools/prompts/resources are invoked. - raw = await call_sync_fn_in_threadpool( - handler, params.ref, params.argument, params.context - ) - result = await raw if inspect.isawaitable(raw) else raw - completion = normalize_completion(cast(CompletionValues, result)) - return mcp_types.CompleteResult(completion=completion) - - def _register_completion_handler(self: FastMCP) -> None: - """Register the low-level ``completion/complete`` handler. - - Called when a completion handler is set (via - ``add_completion_handler``) so the SDK derives the completions - capability from the handler's presence. Registration is idempotent — - re-registering replaces the handler. - """ - self._mcp_server.add_request_handler( - "completion/complete", - CompleteRequestParams, - self._on_complete, - ) diff --git a/fastmcp_slim/fastmcp/server/mixins/transport.py b/fastmcp_slim/fastmcp/server/mixins/transport.py index b26e02d18..ddc96db20 100644 --- a/fastmcp_slim/fastmcp/server/mixins/transport.py +++ b/fastmcp_slim/fastmcp/server/mixins/transport.py @@ -11,13 +11,13 @@ import anyio import uvicorn from mcp.server.lowlevel.server import NotificationOptions from mcp.server.stdio import stdio_server -from mcp.server.streamable_http import EventStore from starlette.middleware import Middleware as ASGIMiddleware from starlette.requests import Request from starlette.responses import Response from starlette.routing import BaseRoute, Route import fastmcp +from fastmcp.server.event_store import EventStore from fastmcp.server.http import ( HostOriginProtection, StarletteWithLifespan, @@ -28,6 +28,7 @@ from fastmcp.server.http import ( from fastmcp.server.providers.base import Provider from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.server.providers.wrapped_provider import _WrappedProvider +from fastmcp.utilities.cli import log_server_banner from fastmcp.utilities.logging import get_logger, temporary_log_level if TYPE_CHECKING: @@ -229,8 +230,6 @@ class TransportMixin: # Display server banner if show_banner: - from fastmcp.utilities.cli import log_server_banner - log_server_banner(server=self) token = set_transport("stdio") @@ -338,8 +337,6 @@ class TransportMixin: # Display server banner if show_banner: - from fastmcp.utilities.cli import log_server_banner - log_server_banner(server=self) uvicorn_config_from_user = uvicorn_config or {} diff --git a/fastmcp_slim/fastmcp/server/providers/__init__.py b/fastmcp_slim/fastmcp/server/providers/__init__.py index f138404f2..fe0c248f9 100644 --- a/fastmcp_slim/fastmcp/server/providers/__init__.py +++ b/fastmcp_slim/fastmcp/server/providers/__init__.py @@ -36,6 +36,7 @@ from fastmcp.server.providers.skills import ( ClaudeSkillsProvider, SkillProvider, SkillsDirectoryProvider, + SkillsProvider, ) if TYPE_CHECKING: @@ -53,6 +54,7 @@ __all__ = [ "ProxyProvider", "SkillProvider", "SkillsDirectoryProvider", + "SkillsProvider", # Backwards compatibility alias for SkillsDirectoryProvider ] diff --git a/fastmcp_slim/fastmcp/server/providers/addressing.py b/fastmcp_slim/fastmcp/server/providers/addressing.py index 7a9b09d4b..71a70a077 100644 --- a/fastmcp_slim/fastmcp/server/providers/addressing.py +++ b/fastmcp_slim/fastmcp/server/providers/addressing.py @@ -13,16 +13,9 @@ app name + tool name. The hash serves two purposes: and ``read_resource`` synthesize these on demand from the tool's meta. The hash is computed at registration time from ``(app_name, tool_name)`` — -both known at that moment — and stored in ``meta["fastmcp"]["tool_hash"]``. +both known at that moment — and stored in ``meta["fastmcp"]["_tool_hash"]``. Deterministic across replicas (same code → same hash), no registry walk needed. - -The key is deliberately public. Keys prefixed with ``_`` inside the -``fastmcp`` meta namespace are stripped at every serialization boundary -(see ``FastMCPComponent.get_meta``) because they hold process-local state -such as enabled/disabled marks. The hash is the opposite: a stable -identity that intermediaries need in order to recognize a tool they are -forwarding, so it must survive the wire. """ from __future__ import annotations @@ -32,9 +25,6 @@ import hashlib #: Length of the hex hash prefix used in URIs and backend-tool names. HASH_LENGTH = 12 -#: Key inside the ``fastmcp`` meta namespace holding a tool's identity hash. -TOOL_HASH_META_KEY = "tool_hash" - def hash_tool(app_name: str, tool_name: str) -> str: """Deterministic hex hash for a tool in an app. diff --git a/fastmcp_slim/fastmcp/server/providers/aggregate.py b/fastmcp_slim/fastmcp/server/providers/aggregate.py index b6ee36ae9..766d64846 100644 --- a/fastmcp_slim/fastmcp/server/providers/aggregate.py +++ b/fastmcp_slim/fastmcp/server/providers/aggregate.py @@ -25,7 +25,7 @@ from collections.abc import AsyncIterator, Sequence from contextlib import AsyncExitStack, asynccontextmanager from typing import TYPE_CHECKING, Literal, TypeVar -from fastmcp.exceptions import NotFoundError, ToolError +from fastmcp.exceptions import NotFoundError from fastmcp.server.providers.base import Provider from fastmcp.server.transforms import Namespace from fastmcp.utilities.async_utils import gather @@ -190,7 +190,7 @@ class AggregateProvider(Provider): async def _list_tools(self) -> Sequence[Tool]: """List all tools from all providers.""" results = await gather( - (p.list_tools() for p in self.providers), + *[p.list_tools() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "list_tools") @@ -200,7 +200,7 @@ class AggregateProvider(Provider): ) -> Tool | None: """Get tool by name from providers.""" results = await gather( - (p.get_tool(name, version) for p in self.providers), + *[p.get_tool(name, version) for p in self.providers], return_exceptions=True, ) return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] @@ -208,7 +208,7 @@ class AggregateProvider(Provider): async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: """Query all child providers for an app tool.""" results = await gather( - (p.get_app_tool(app_name, tool_name) for p in self.providers), + *[p.get_app_tool(app_name, tool_name) for p in self.providers], return_exceptions=True, ) for r in results: @@ -221,41 +221,19 @@ class AggregateProvider(Provider): return None async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None: - """Query all child providers for a tool matching a hash. - - The hash identifies a tool by app name and registered name, with no - mount-point component, so composing one app into two branches yields - two distinct tools claiming the same identity. That is ambiguous - rather than resolvable: picking either one silently routes a UI's - call into the wrong branch. Raise instead. - - An ambiguity raised by a child is a verdict, not a provider failure, - so it propagates whatever the error strategy is. Swallowing it would - turn a duplicated app into "unknown tool", which sends whoever hits - it looking for a missing registration instead of a duplicate one. - """ + """Query all child providers for a tool matching a hash.""" results = await gather( - (p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers), + *[p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers], return_exceptions=True, ) - matches: list[Tool] = [] for r in results: if isinstance(r, BaseException): - if isinstance(r, ToolError) or self.provider_error_strategy == "raise": + if self.provider_error_strategy == "raise": raise r continue if r is not None: - matches.append(r) - - if not matches: - return None - if len(matches) > 1: - raise ToolError( - f"Ambiguous app tool {tool_name!r}: {len(matches)} components share " - f"the identity {tool_hash!r}. The same app is composed more than " - f"once, so this call cannot be routed to a single tool." - ) - return matches[0] + return r + return None # ------------------------------------------------------------------------- # Resources @@ -264,7 +242,7 @@ class AggregateProvider(Provider): async def _list_resources(self) -> Sequence[Resource]: """List all resources from all providers.""" results = await gather( - (p.list_resources() for p in self.providers), + *[p.list_resources() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "list_resources") @@ -274,7 +252,7 @@ class AggregateProvider(Provider): ) -> Resource | None: """Get resource by URI from providers.""" results = await gather( - (p.get_resource(uri, version) for p in self.providers), + *[p.get_resource(uri, version) for p in self.providers], return_exceptions=True, ) return self._get_highest_version_result(results, f"get_resource({uri!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] @@ -286,7 +264,7 @@ class AggregateProvider(Provider): async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """List all resource templates from all providers.""" results = await gather( - (p.list_resource_templates() for p in self.providers), + *[p.list_resource_templates() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "list_resource_templates") @@ -296,7 +274,7 @@ class AggregateProvider(Provider): ) -> ResourceTemplate | None: """Get resource template by URI from providers.""" results = await gather( - (p.get_resource_template(uri, version) for p in self.providers), + *[p.get_resource_template(uri, version) for p in self.providers], return_exceptions=True, ) return self._get_highest_version_result( @@ -310,7 +288,7 @@ class AggregateProvider(Provider): async def _list_prompts(self) -> Sequence[Prompt]: """List all prompts from all providers.""" results = await gather( - (p.list_prompts() for p in self.providers), + *[p.list_prompts() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "list_prompts") @@ -320,7 +298,7 @@ class AggregateProvider(Provider): ) -> Prompt | None: """Get prompt by name from providers.""" results = await gather( - (p.get_prompt(name, version) for p in self.providers), + *[p.get_prompt(name, version) for p in self.providers], return_exceptions=True, ) return self._get_highest_version_result(results, f"get_prompt({name!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] @@ -332,7 +310,7 @@ class AggregateProvider(Provider): async def get_tasks(self) -> Sequence[FastMCPComponent]: """Get all task-eligible components from all providers.""" results = await gather( - (p.get_tasks() for p in self.providers), + *[p.get_tasks() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "get_tasks") diff --git a/fastmcp_slim/fastmcp/server/providers/base.py b/fastmcp_slim/fastmcp/server/providers/base.py index 7941039dd..402dff351 100644 --- a/fastmcp_slim/fastmcp/server/providers/base.py +++ b/fastmcp_slim/fastmcp/server/providers/base.py @@ -214,11 +214,9 @@ class Provider: """Look up an app-visible tool by its deterministic hash. Same recursive-walk semantics as ``get_app_tool`` but matches on - ``meta["fastmcp"]["tool_hash"]`` instead of the app name tag. + ``meta["fastmcp"]["_tool_hash"]`` instead of the app name tag. Used by the dispatcher when receiving hashed backend-tool calls. """ - from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY - tool = await self._get_tool(tool_name) if tool is not None: meta = tool.meta or {} @@ -229,7 +227,7 @@ class Provider: ) if ( isinstance(fastmcp_meta, dict) - and fastmcp_meta.get(TOOL_HASH_META_KEY) == tool_hash + and fastmcp_meta.get("_tool_hash") == tool_hash and "app" in visibility ): return tool @@ -498,19 +496,12 @@ class Provider: Used by the server during startup to register functions with Docket. """ - # Fetch all component types in parallel. Iterate the bound methods - # rather than a tuple of already-called coroutines: a parenthesized - # comma expression is a tuple, so it would create all four coroutines - # before `gather` starts, which is exactly what `gather` asks callers - # to avoid. + # Fetch all component types in parallel results = await gather( - fetch() - for fetch in ( - self._list_tools, - self._list_resources, - self._list_resource_templates, - self._list_prompts, - ) + self._list_tools(), + self._list_resources(), + self._list_resource_templates(), + self._list_prompts(), ) tools = cast("Sequence[Tool]", results[0]) resources = cast("Sequence[Resource]", results[1]) diff --git a/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py b/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py index 7cb9d9213..27787d2ef 100644 --- a/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py +++ b/fastmcp_slim/fastmcp/server/providers/fastmcp_provider.py @@ -12,20 +12,25 @@ from __future__ import annotations from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, overload +import mcp_types from pydantic import AnyUrl from fastmcp.prompts.base import Prompt, PromptResult from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate, expand_uri_template from fastmcp.server.providers.base import Provider +from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.telemetry import delegate_span from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution + from fastmcp.server.server import FastMCP @@ -75,13 +80,30 @@ class FastMCPProviderTool(Tool): icons=tool.icons, ) - async def _run(self, arguments: dict[str, Any]) -> ToolResult: - """Delegate to the child server's call_tool(). + @overload + async def _run( + self, + arguments: dict[str, Any], + task_meta: None = None, + ) -> ToolResult: ... - fn_key is already set by the parent server before calling this method. A - child tool that requests client input (SEP-2322) returns an - `InputRequiredToolResult`, which forwards through this delegation to the - parent's wire handler unchanged. + @overload + async def _run( + self, + arguments: dict[str, Any], + task_meta: TaskMeta, + ) -> mcp_types.CreateTaskResult: ... + + async def _run( + self, + arguments: dict[str, Any], + task_meta: TaskMeta | None = None, + ) -> ToolResult | mcp_types.CreateTaskResult: + """Delegate to child server's call_tool() with task_meta. + + Passes task_meta through to the child server so it can handle + backgrounding appropriately. fn_key is already set by the parent + server before calling this method. """ # Pass exact version so child executes the correct version version = VersionSpec(eq=self.version) if self.version else None @@ -96,20 +118,27 @@ class FastMCPProviderTool(Tool): self._original_name, arguments, version=version, + task_meta=task_meta, ) async def run(self, arguments: dict[str, Any]) -> ToolResult: - """Delegate to the child server's call_tool(). + """Delegate to child server's call_tool() without task_meta. This is called when the tool is used within a TransformedTool - forwarding function or other contexts. + forwarding function or other contexts where task_meta is not available. """ # Pass exact version so child executes the correct version version = VersionSpec(eq=self.version) if self.version else None - return await self._server.call_tool( + result = await self._server.call_tool( self._original_name, arguments, version=version ) + # Result from call_tool should always be ToolResult when no task_meta + if isinstance(result, mcp_types.CreateTaskResult): + raise RuntimeError( + "Unexpected CreateTaskResult from call_tool without task_meta" + ) + return result def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { @@ -157,10 +186,20 @@ class FastMCPProviderResource(Resource): icons=resource.icons, ) - async def _read(self) -> ResourceResult: - """Delegate to the child server's read_resource(). + @overload + async def _read(self, task_meta: None = None) -> ResourceResult: ... - fn_key is already set by the parent server before calling this method. + @overload + async def _read(self, task_meta: TaskMeta) -> mcp_types.CreateTaskResult: ... + + async def _read( + self, task_meta: TaskMeta | None = None + ) -> ResourceResult | mcp_types.CreateTaskResult: + """Delegate to child server's read_resource() with task_meta. + + Passes task_meta through to the child server so it can handle + backgrounding appropriately. fn_key is already set by the parent + server before calling this method. """ # Pass exact version so child reads the correct version version = VersionSpec(eq=self.version) if self.version else None @@ -171,7 +210,9 @@ class FastMCPProviderResource(Resource): self._original_uri or "", method="resources/read", ): - return await self._server.read_resource(self._original_uri, version=version) + return await self._server.read_resource( + self._original_uri, version=version, task_meta=task_meta + ) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { @@ -217,10 +258,30 @@ class FastMCPProviderPrompt(Prompt): icons=prompt.icons, ) - async def _render(self, arguments: dict[str, Any] | None = None) -> PromptResult: - """Delegate to the child server's render_prompt(). + @overload + async def _render( + self, + arguments: dict[str, Any] | None = None, + task_meta: None = None, + ) -> PromptResult: ... - fn_key is already set by the parent server before calling this method. + @overload + async def _render( + self, + arguments: dict[str, Any] | None, + task_meta: TaskMeta, + ) -> mcp_types.CreateTaskResult: ... + + async def _render( + self, + arguments: dict[str, Any] | None = None, + task_meta: TaskMeta | None = None, + ) -> PromptResult | mcp_types.CreateTaskResult: + """Delegate to child server's render_prompt() with task_meta. + + Passes task_meta through to the child server so it can handle + backgrounding appropriately. fn_key is already set by the parent + server before calling this method. """ # Pass exact version so child renders the correct version version = VersionSpec(eq=self.version) if self.version else None @@ -232,21 +293,27 @@ class FastMCPProviderPrompt(Prompt): method="prompts/get", ): return await self._server.render_prompt( - self._original_name, arguments, version=version + self._original_name, arguments, version=version, task_meta=task_meta ) async def render(self, arguments: dict[str, Any] | None = None) -> PromptResult: - """Delegate to the child server's render_prompt(). + """Delegate to child server's render_prompt() without task_meta. This is called when the prompt is used within a transformed context - or other contexts. + or other contexts where task_meta is not available. """ # Pass exact version so child renders the correct version version = VersionSpec(eq=self.version) if self.version else None - return await self._server.render_prompt( + result = await self._server.render_prompt( self._original_name, arguments, version=version ) + # Result from render_prompt should always be PromptResult when no task_meta + if isinstance(result, mcp_types.CreateTaskResult): + raise RuntimeError( + "Unexpected CreateTaskResult from render_prompt without task_meta" + ) + return result def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { @@ -322,10 +389,24 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): icons=self.icons, ) - async def _read(self, uri: str, params: dict[str, Any]) -> ResourceResult: - """Delegate to the child server's read_resource(). + @overload + async def _read( + self, uri: str, params: dict[str, Any], task_meta: None = None + ) -> ResourceResult: ... - fn_key is already set by the parent server before calling this method. + @overload + async def _read( + self, uri: str, params: dict[str, Any], task_meta: TaskMeta + ) -> mcp_types.CreateTaskResult: ... + + async def _read( + self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None + ) -> ResourceResult | mcp_types.CreateTaskResult: + """Delegate to child server's read_resource() with task_meta. + + Passes task_meta through to the child server so it can handle + backgrounding appropriately. fn_key is already set by the parent + server before calling this method. """ # Expand the original template with params to get internal URI original_uri = expand_uri_template(self._original_uri_template or "", params) @@ -339,7 +420,50 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): self._original_uri_template or "", method="resources/read", ): - return await self._server.read_resource(original_uri, version=version) + return await self._server.read_resource( + original_uri, version=version, task_meta=task_meta + ) + + async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: + """Read the resource content for background task execution. + + Reads the resource via the wrapped server and returns the ResourceResult. + This method is called by Docket during background task execution. + """ + # Expand the original template with arguments to get internal URI + original_uri = expand_uri_template(self._original_uri_template or "", arguments) + + # Pass exact version so child reads the correct version + version = VersionSpec(eq=self.version) if self.version else None + + # Read from the wrapped server + result = await self._server.read_resource(original_uri, version=version) + if isinstance(result, mcp_types.CreateTaskResult): + raise RuntimeError("Unexpected CreateTaskResult during Docket execution") + + return result + + def register_with_docket(self, docket: Docket) -> None: + """No-op: the child's actual template is registered via get_tasks().""" + + async def add_to_docket( + self, + docket: Docket, + params: dict[str, Any], + *, + fn_key: str | None = None, + task_key: str | None = None, + **kwargs: Any, + ) -> Execution: + """Schedule this template for background execution via docket. + + The child's FunctionResourceTemplate.fn is registered (via get_tasks), + and it expects splatted **kwargs, so we splat params here. + """ + lookup_key = fn_key or self.key + if task_key: + kwargs["key"] = task_key + return await docket.add(lookup_key, **kwargs)(**params) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { diff --git a/fastmcp_slim/fastmcp/server/providers/filesystem_discovery.py b/fastmcp_slim/fastmcp/server/providers/filesystem_discovery.py index 56c55109a..0c1340ef5 100644 --- a/fastmcp_slim/fastmcp/server/providers/filesystem_discovery.py +++ b/fastmcp_slim/fastmcp/server/providers/filesystem_discovery.py @@ -349,6 +349,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: ) components.append(tool) elif isinstance(meta, ResourceMeta): + resolved_task = meta.task if meta.task is not None else False has_uri_params = "{" in meta.uri and "}" in meta.uri wrapper_fn = without_injected_parameters(obj) has_func_params = bool(inspect.signature(wrapper_fn).parameters) @@ -366,6 +367,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: tags=meta.tags, annotations=meta.annotations, meta=meta.meta, + task=resolved_task, auth=meta.auth, ) else: @@ -381,10 +383,12 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: tags=meta.tags, annotations=meta.annotations, meta=meta.meta, + task=resolved_task, auth=meta.auth, ) components.append(resource) elif isinstance(meta, PromptMeta): + resolved_task = meta.task if meta.task is not None else False prompt = Prompt.from_function( obj, name=meta.name, @@ -394,6 +398,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]: icons=meta.icons, tags=meta.tags, meta=meta.meta, + task=resolved_task, auth=meta.auth, ) components.append(prompt) diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py index d0816325d..ba1621875 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/prompts.py @@ -15,7 +15,8 @@ import mcp_types from fastmcp.prompts.base import Prompt from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.utilities.authorization import AuthCheck +from fastmcp.server.auth.authorization import AuthCheck +from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.types import AnyFunction if TYPE_CHECKING: @@ -44,6 +45,7 @@ class PromptDecoratorMixin: meta = get_fastmcp_meta(prompt) if meta is not None and isinstance(meta, PromptMeta): + resolved_task = meta.task if meta.task is not None else False enabled = meta.enabled prompt = Prompt.from_function( prompt, @@ -54,6 +56,7 @@ class PromptDecoratorMixin: icons=meta.icons, tags=meta.tags, meta=meta.meta, + task=resolved_task, auth=meta.auth, ) else: @@ -79,6 +82,7 @@ class PromptDecoratorMixin: tags: set[str] | None = None, enabled: bool = True, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> F: ... @@ -95,6 +99,7 @@ class PromptDecoratorMixin: tags: set[str] | None = None, enabled: bool = True, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @@ -110,6 +115,7 @@ class PromptDecoratorMixin: tags: set[str] | None = None, enabled: bool = True, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionPrompt] @@ -134,6 +140,7 @@ class PromptDecoratorMixin: tags: Optional set of tags for categorizing the prompt enabled: Whether the prompt is enabled (default True). If False, adds to blocklist. meta: Optional meta information about the prompt + task: Optional task configuration for background execution auth: Optional authorization checks for the prompt Returns: @@ -191,6 +198,7 @@ class PromptDecoratorMixin: icons=icons, tags=tags, meta=meta, + task=task, auth=auth, enabled=enabled, ) @@ -224,5 +232,6 @@ class PromptDecoratorMixin: tags=tags, meta=meta, enabled=enabled, + task=task, auth=auth, ) diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py index f1f7b106b..75d23a967 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/resources.py @@ -20,7 +20,8 @@ from fastmcp.resources.security import ( ResourceSecurity, ) from fastmcp.resources.template import ResourceTemplate -from fastmcp.utilities.authorization import AuthCheck +from fastmcp.server.auth.authorization import AuthCheck +from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.types import AnyFunction if TYPE_CHECKING: @@ -53,6 +54,7 @@ class ResourceDecoratorMixin: meta = get_fastmcp_meta(resource) if meta is not None and isinstance(meta, ResourceMeta): + resolved_task = meta.task if meta.task is not None else False enabled = meta.enabled has_uri_params = "{" in meta.uri and "}" in meta.uri wrapper_fn = without_injected_parameters(resource) @@ -71,6 +73,7 @@ class ResourceDecoratorMixin: tags=meta.tags, annotations=meta.annotations, meta=meta.meta, + task=resolved_task, auth=meta.auth, security=meta.security, ) @@ -87,6 +90,7 @@ class ResourceDecoratorMixin: tags=meta.tags, annotations=meta.annotations, meta=meta.meta, + task=resolved_task, auth=meta.auth, ) else: @@ -119,6 +123,7 @@ class ResourceDecoratorMixin: enabled: bool = True, annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY, ) -> Callable[[F], F]: @@ -138,6 +143,7 @@ class ResourceDecoratorMixin: enabled: Whether the resource is enabled (default True). If False, adds to blocklist. annotations: Optional annotations about the resource's behavior meta: Optional meta information about the resource + task: Optional task configuration for background execution auth: Optional authorization checks for the resource Returns: @@ -200,6 +206,7 @@ class ResourceDecoratorMixin: mime_type=mime_type, annotations=annotations, meta=meta, + task=task, auth=auth, enabled=enabled, security=security, diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py index 3f4b322a5..dcf1c0a2d 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/decorators/tools.py @@ -25,13 +25,20 @@ from typing import ( import mcp_types from mcp_types import ToolAnnotations +from fastmcp.server.auth.authorization import AuthCheck +from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import FunctionTool -from fastmcp.utilities.authorization import AuthCheck -from fastmcp.utilities.prefab import is_prefab_type, prefab_available -from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import AnyFunction, NotSet, NotSetT +try: + from prefab_ui.app import PrefabApp as _PrefabApp + from prefab_ui.components.base import Component as _PrefabComponent + + _HAS_PREFAB = True +except ImportError: + _HAS_PREFAB = False + if TYPE_CHECKING: from fastmcp.server.providers.local_provider import LocalProvider @@ -44,7 +51,7 @@ PREFAB_RENDERER_URI = "ui://prefab/renderer.html" def _is_prefab_type(tp: Any) -> bool: """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" - if is_prefab_type(tp): + if isinstance(tp, type) and issubclass(tp, (_PrefabApp, _PrefabComponent)): return True origin = get_origin(tp) if origin is Union or origin is types.UnionType or origin is Annotated: @@ -54,7 +61,7 @@ def _is_prefab_type(tp: Any) -> bool: def _has_prefab_return_type(tool: Tool) -> bool: """Check if a FunctionTool's return type annotation is a prefab type.""" - if not isinstance(tool, FunctionTool): + if not _HAS_PREFAB or not isinstance(tool, FunctionTool): return False rt = tool.return_type if rt is None or rt is inspect.Parameter.empty: @@ -87,10 +94,13 @@ def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None: it. ``app=True``, return-type inference, and ``PrefabAppConfig`` all funnel through the same placeholder marker. """ + if not _HAS_PREFAB: + return + meta = tool.meta or {} ui = meta.get("ui") - if ui is True and prefab_available(): + if ui is True: # Explicit app=True: stamp the placeholder so the synthesizer finds it. _stamp_prefab_marker(tool) elif ui is None and _has_prefab_return_type(tool): diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/README.md b/fastmcp_slim/fastmcp/server/providers/openapi/README.md index 8b55d8753..8c5e890c4 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/README.md +++ b/fastmcp_slim/fastmcp/server/providers/openapi/README.md @@ -53,7 +53,7 @@ The main server class orchestrates the stateless request building approach: ```python class FastMCPOpenAPI(FastMCP): - def __init__(self, openapi_spec: dict, client: httpx2.AsyncClient, **kwargs): + def __init__(self, openapi_spec: dict, client: httpx.AsyncClient, **kwargs): # 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas self._routes = parse_openapi_to_http_routes(openapi_spec) @@ -92,7 +92,7 @@ OpenAPI Spec → HTTPRoute with Pre-calculated Fields → RequestDirector → HT 2. **RequestDirector Setup**: openapi-core Spec initialized for request building 3. **Component Creation**: Create components with RequestDirector reference 4. **Request Building**: RequestDirector builds HTTP request from flat parameters -5. **Request Execution**: Execute request with httpx2 client +5. **Request Execution**: Execute request with httpx client 6. **Response Processing**: Return structured MCP response ## Key Features @@ -263,4 +263,4 @@ logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG) - `/utilities/openapi_new/README.md` - Utility implementation details - `/server/openapi/README.md` - Legacy implementation reference - `/tests/server/openapi_new/` - Comprehensive test suite -- Project documentation on OpenAPI integration patterns +- Project documentation on OpenAPI integration patterns \ No newline at end of file diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/components.py b/fastmcp_slim/fastmcp/server/providers/openapi/components.py index a6b88538e..3b981226e 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/components.py +++ b/fastmcp_slim/fastmcp/server/providers/openapi/components.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx2 from mcp_types import ToolAnnotations @@ -17,12 +17,16 @@ from fastmcp.resources import ( ResourceTemplate, ) from fastmcp.server.dependencies import get_http_headers +from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.exceptions import is_request_error, is_timeout_error +from fastmcp.utilities.exceptions import ( + HTTP_STATUS_ERRORS, + REQUEST_ERRORS, + TIMEOUT_ERRORS, +) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import HTTPRoute from fastmcp.utilities.openapi.director import RequestDirector -from fastmcp.utilities.tasks import TaskConfig if TYPE_CHECKING: from fastmcp.server import Context @@ -59,36 +63,6 @@ logger = get_logger(__name__) _DEFAULT_MIME_TYPE = "application/json" -def _raise_for_status(response: httpx2.Response) -> None: - """Raise an OpenAPI-formatted error without relying on client exception types.""" - if 200 <= response.status_code < 300: - return - - error_message = f"HTTP error {response.status_code}: {response.reason_phrase}" - try: - error_data = response.json() - error_message += f" - {error_data}" - except (json.JSONDecodeError, ValueError): - if response.text: - error_message += f" - {response.text}" - raise ValueError(error_message) - - -async def _send_request( - client: httpx2.AsyncClient, - request: httpx2.Request, -) -> httpx2.Response: - """Send a request while preserving transitional legacy-client errors.""" - try: - return await client.send(request) - except Exception as exc: - if is_timeout_error(exc): - raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc - if is_request_error(exc): - raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc - raise - - def _extract_mime_type_from_route(route: HTTPRoute) -> str: """Extract the primary MIME type from an HTTPRoute's response definitions. @@ -202,8 +176,12 @@ class OpenAPITool(Tool): base_url = str(self._client.base_url) or "http://localhost" directed_request = self._director.build(self._route, arguments, base_url) - # Rebuild through the configured client so its default headers are - # merged with the directed headers taking priority. + # Rebuild through the user's client so the request object comes + # from whichever httpx library the client belongs to (a legacy + # httpx.AsyncClient cannot send an httpx2.Request). Primitive + # values (str/bytes/tuples) cross that boundary safely; client + # default headers merge in with directed headers taking priority, + # matching the previous manual merge. request = self._client.build_request( method=directed_request.method, url=str(directed_request.url.copy_with(query=None)), @@ -232,8 +210,8 @@ class OpenAPITool(Tool): f"run - sending request; headers: {_redact_headers(request.headers)}" ) - response = await _send_request(self._client, request) - _raise_for_status(response) + response = await self._client.send(request) + response.raise_for_status() # Try to parse as JSON first try: @@ -260,11 +238,25 @@ class OpenAPITool(Tool): except json.JSONDecodeError: return ToolResult(content=response.text) - except httpx2.TimeoutException as exc: - raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc + except HTTP_STATUS_ERRORS as e: + status_error = cast("httpx2.HTTPStatusError", e) + error_message = ( + f"HTTP error {status_error.response.status_code}: " + f"{status_error.response.reason_phrase}" + ) + try: + error_data = status_error.response.json() + error_message += f" - {error_data}" + except (json.JSONDecodeError, ValueError): + if status_error.response.text: + error_message += f" - {status_error.response.text}" + raise ValueError(error_message) from e - except httpx2.RequestError as exc: - raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc + except TIMEOUT_ERRORS as e: + raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + + except REQUEST_ERRORS as e: + raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e class OpenAPIResource(Resource): @@ -306,7 +298,8 @@ class OpenAPIResource(Resource): directed_request = self._director.build( self._route, self._arguments, base_url ) - # Build through the configured client so its defaults are applied. + # Primitive values only: a legacy httpx.AsyncClient cannot accept + # httpx2 URL/QueryParams/Headers objects. request = self._client.build_request( method=directed_request.method, url=str(directed_request.url.copy_with(query=None)), @@ -321,8 +314,8 @@ class OpenAPIResource(Resource): if mcp_headers: request.headers.update(mcp_headers) - response = await _send_request(self._client, request) - _raise_for_status(response) + response = await self._client.send(request) + response.raise_for_status() content_type = response.headers.get("content-type", "").lower() @@ -350,11 +343,25 @@ class OpenAPIResource(Resource): ] ) - except httpx2.TimeoutException as exc: - raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc + except HTTP_STATUS_ERRORS as e: + status_error = cast("httpx2.HTTPStatusError", e) + error_message = ( + f"HTTP error {status_error.response.status_code}: " + f"{status_error.response.reason_phrase}" + ) + try: + error_data = status_error.response.json() + error_message += f" - {error_data}" + except (json.JSONDecodeError, ValueError): + if status_error.response.text: + error_message += f" - {status_error.response.text}" + raise ValueError(error_message) from e - except httpx2.RequestError as exc: - raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc + except TIMEOUT_ERRORS as e: + raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + + except REQUEST_ERRORS as e: + raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str: diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py index c16f14034..4048479a0 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py +++ b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py @@ -2,7 +2,6 @@ from __future__ import annotations -import warnings from collections import Counter from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager @@ -11,7 +10,6 @@ from typing import Any, Literal, cast import httpx2 from jsonschema_path import SchemaPath -from fastmcp._warnings import FastMCPDeprecationWarning from fastmcp.prompts import Prompt from fastmcp.resources import Resource, ResourceTemplate from fastmcp.server.providers.base import Provider @@ -50,14 +48,6 @@ logger = get_logger(__name__) DEFAULT_TIMEOUT: float = 30.0 -def _is_legacy_httpx_client(client: object) -> bool: - """Detect a legacy httpx client without importing the legacy package.""" - return any( - cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == "AsyncClient" - for cls in type(client).__mro__ - ) - - class OpenAPIProvider(Provider): """Provider that creates MCP components from an OpenAPI specification. @@ -94,12 +84,10 @@ class OpenAPIProvider(Provider): Args: openapi_spec: OpenAPI schema as a dictionary - client: Optional httpx2 AsyncClient for making HTTP requests. + client: Optional httpx AsyncClient for making HTTP requests. If not provided, a default client is created using the first server URL from the OpenAPI spec with a 30-second timeout. To customize timeout or other settings, pass your own client. - Legacy httpx clients are temporarily accepted with a deprecation - warning. route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization @@ -115,14 +103,6 @@ class OpenAPIProvider(Provider): self._owns_client = client is None if client is None: client = self._create_default_client(openapi_spec) - elif _is_legacy_httpx_client(client): - warnings.warn( - "Passing an httpx.AsyncClient to OpenAPIProvider is deprecated " - "and will be removed in a future release. Pass an " - "httpx2.AsyncClient instead.", - FastMCPDeprecationWarning, - stacklevel=2, - ) self._client = client self._mcp_component_fn = mcp_component_fn self._validate_output = validate_output diff --git a/fastmcp_slim/fastmcp/server/providers/prefab_payload.py b/fastmcp_slim/fastmcp/server/providers/prefab_payload.py deleted file mode 100644 index 9741954d1..000000000 --- a/fastmcp_slim/fastmcp/server/providers/prefab_payload.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Late-bound tool names in Prefab UI payloads. - -A Prefab UI is serialized during the entry tool's call, deep inside whatever -composition the server happens to have. At that moment nothing knows what the -backend tools will be *called* by the time the payload reaches a host: every -layer above may rename them, and the outermost layer's names are the only ones -a client can actually invoke. - -So the payload leaves the app addressed by identity — ``<hash>_<local_name>``, -stable everywhere — and every FastMCP server rewrites those references on the -way out to whatever it lists that tool as. Servers rewrite innermost-first, so -the edge writes last and wins. - -Rewriting a name in place would destroy the identity for the next layer up, so -the payload carries a name-to-identity map under ``_meta.fastmcp.toolNames``. -Each layer resolves through the map and updates it. The action objects keep the -exact shape ``prefab_ui`` defines — only the value of ``tool`` changes, and only -ever to another valid tool name. - -Renderers read ``_meta`` already and ignore keys they don't recognize, so this -needs no renderer change. -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -from fastmcp.server.providers.addressing import parse_hashed_backend_name - -#: Action discriminator emitted by ``prefab_ui``'s ``CallTool``. -_TOOL_CALL_ACTION = "toolCall" - -_META_KEY = "_meta" -_FASTMCP_KEY = "fastmcp" -_TOOL_NAMES_KEY = "toolNames" - -#: Resolves an identity hash to the name this server lists that tool under. -#: Returns None when the identity cannot be resolved here, in which case the -#: existing reference is left alone. -IdentityResolver = Callable[[str], str | None] - - -def _walk_tool_calls(node: Any) -> list[dict[str, Any]]: - """Collect every ``toolCall`` action object in a payload tree.""" - found: list[dict[str, Any]] = [] - if isinstance(node, dict): - if node.get("action") == _TOOL_CALL_ACTION and isinstance( - node.get("tool"), str - ): - found.append(node) - for value in node.values(): - found.extend(_walk_tool_calls(value)) - elif isinstance(node, list): - for item in node: - found.extend(_walk_tool_calls(item)) - return found - - -def _read_map(payload: dict[str, Any]) -> dict[str, str]: - meta = payload.get(_META_KEY) - if not isinstance(meta, dict): - return {} - fastmcp_meta = meta.get(_FASTMCP_KEY) - if not isinstance(fastmcp_meta, dict): - return {} - names = fastmcp_meta.get(_TOOL_NAMES_KEY) - if not isinstance(names, dict): - return {} - return {k: v for k, v in names.items() if isinstance(k, str) and isinstance(v, str)} - - -def _write_map(payload: dict[str, Any], names: dict[str, str]) -> None: - meta = payload.setdefault(_META_KEY, {}) - if not isinstance(meta, dict): - return - fastmcp_meta = meta.setdefault(_FASTMCP_KEY, {}) - if not isinstance(fastmcp_meta, dict): - return - fastmcp_meta[_TOOL_NAMES_KEY] = names - - -def payload_has_identities(payload: Any) -> bool: - """Cheap guard: does this payload carry tool references worth rewriting? - - Runs on every tool result, so it must not walk the tree. - """ - return isinstance(payload, dict) and bool(_read_map(payload)) - - -def annotate_payload_identities(payload: dict[str, Any]) -> dict[str, Any]: - """Record the identity-addressed form of each reference, at serialization. - - References start out as ``<hash>_<local_name>``, so the map begins as an - identity map to itself. Once a later layer rewrites a name, this is the - only remaining route back: it carries both what the reference points at - and the address any server can fall back to. - """ - if not isinstance(payload, dict): - return payload - - addresses: dict[str, str] = dict(_read_map(payload)) - for action in _walk_tool_calls(payload): - tool_name = action["tool"] - if tool_name in addresses: - continue - if parse_hashed_backend_name(tool_name) is not None: - addresses[tool_name] = tool_name - - if addresses: - _write_map(payload, addresses) - return payload - - -def rewrite_payload_tool_names( - payload: Any, - resolve: IdentityResolver, -) -> Any: - """Re-address a payload's tool references to this server's own names. - - Mutates in place and returns the payload. - - A reference this server cannot resolve is restored to its - identity-addressed form rather than left as-is. Leaving it would strand - whatever name an inner server chose — a name that is correct there and - meaningless here — and, unlike the identity form, a stranded name has no - route back. Restoring keeps the reference resolvable by the dispatcher, - or by any server further out with a better view. - """ - if not isinstance(payload, dict): - return payload - - addresses = _read_map(payload) - if not addresses: - return payload - - rebound: dict[str, str] = {} - for current_name, address in addresses.items(): - parsed = parse_hashed_backend_name(address) - new_name = resolve(parsed[0]) if parsed is not None else None - if new_name is None: - new_name = address - if new_name != current_name: - rebound[current_name] = new_name - - if not rebound: - return payload - - for action in _walk_tool_calls(payload): - new_name = rebound.get(action["tool"]) - if new_name is not None: - action["tool"] = new_name - - _write_map( - payload, - {rebound.get(name, name): address for name, address in addresses.items()}, - ) - return payload diff --git a/fastmcp_slim/fastmcp/server/providers/prefab_synthesis.py b/fastmcp_slim/fastmcp/server/providers/prefab_synthesis.py index 111cd677e..e47bd0391 100644 --- a/fastmcp_slim/fastmcp/server/providers/prefab_synthesis.py +++ b/fastmcp_slim/fastmcp/server/providers/prefab_synthesis.py @@ -2,7 +2,7 @@ Tools marked as Prefab (via ``app=True``, ``PrefabAppConfig``, etc.) carry a placeholder ``meta.ui.resourceUri`` and optionally a hash in -``meta.fastmcp.tool_hash``. This module synthesizes per-tool renderer +``meta.fastmcp._tool_hash``. This module synthesizes per-tool renderer resources on demand at ``list_resources`` and ``read_resource`` time without storing or materializing anything. @@ -19,7 +19,6 @@ from typing import TYPE_CHECKING, Any, cast from fastmcp.server.providers.addressing import ( HASH_LENGTH, - TOOL_HASH_META_KEY, hash_tool, parse_hashed_resource_uri, ) @@ -49,7 +48,7 @@ def _get_tool_hash(tool: Tool) -> str | None: meta = tool.meta or {} fastmcp_meta = meta.get("fastmcp") if isinstance(fastmcp_meta, dict): - h = fastmcp_meta.get(TOOL_HASH_META_KEY) + h = fastmcp_meta.get("_tool_hash") if isinstance(h, str) and len(h) == HASH_LENGTH: return h # Fall back to computing from app name diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index 931c49594..72f48040f 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -10,49 +10,36 @@ from __future__ import annotations import base64 import inspect import time -import warnings from collections.abc import Awaitable, Callable, Sequence -from copy import deepcopy -from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, cast import anyio import httpx2 import mcp_types -from mcp import ClientSession from mcp.server.connection import Connection from mcp.server.context import ServerRequestContext from mcp.shared.exceptions import MCPError -from mcp.shared.inbound import x_mcp_header_map from mcp_types import ( METHOD_NOT_FOUND, BlobResourceContents, ElicitRequestFormParams, TextResourceContents, ) -from mcp_types.version import MODERN_PROTOCOL_VERSIONS -from pydantic import ValidationError from pydantic.networks import AnyUrl -from fastmcp._warnings import FastMCPDeprecationWarning -from fastmcp.client.client import Client, SDKServer, _connection_failure +from fastmcp.client.client import Client, SDKServer from fastmcp.client.elicitation import ElicitResult, create_elicitation_callback from fastmcp.client.logging import LogMessage, create_log_callback from fastmcp.client.roots import RootsList, create_roots_callback from fastmcp.client.sampling import create_sampling_callback from fastmcp.client.telemetry import client_span from fastmcp.client.transports import ClientTransportT -from fastmcp.client.transports.base import TransportOptions -from fastmcp.exceptions import ResourceError, ToolError +from fastmcp.exceptions import ResourceError from fastmcp.mcp_config import MCPConfig from fastmcp.prompts import Message, Prompt, PromptResult -from fastmcp.prompts.base import InputRequiredPromptResult, PromptArgument +from fastmcp.prompts.base import PromptArgument from fastmcp.resources import Resource, ResourceTemplate -from fastmcp.resources.base import ( - InputRequiredResourceResult, - ResourceContent, - ResourceResult, -) +from fastmcp.resources.base import ResourceContent, ResourceResult from fastmcp.resources.template import expand_uri_template from fastmcp.server.context import Context from fastmcp.server.dependencies import fastmcp_request_ctx, get_context @@ -60,11 +47,10 @@ from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.providers.aggregate import ProviderErrorStrategy from fastmcp.server.providers.base import Provider from fastmcp.server.server import FastMCP -from fastmcp.telemetry import inject_trace_context -from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult +from fastmcp.server.tasks.config import TaskConfig +from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.components import FastMCPComponent, get_fastmcp_metadata from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.versions import VersionSpec, version_sort_key if TYPE_CHECKING: @@ -76,162 +62,15 @@ logger = get_logger(__name__) # Type alias for client factory functions ClientFactoryT = Callable[[], Client] | Callable[[], Awaitable[Client]] -ProxyIdentity = Literal["proxy", "upstream"] - - -class _ForwardingClientSession(ClientSession): - """A session that does not enforce the backend's declared output schema. - - `ClientSession.call_tool` normally validates a tool's structured content - against the output schema the backend advertised, raising if they disagree. - That check belongs to whoever consumes the result. A proxy only relays it, - and the end client runs the same check for itself, so enforcing it mid-path - turns a backend's schema bug into a proxy error and hides the real response. - """ - - async def validate_tool_result( - self, name: str, result: mcp_types.CallToolResult - ) -> None: - return None - - -# Settings every proxy-backend connection uses: relay results without policing -# the backend's output schema, and forward the caller's authorization header -# upstream (appropriate for a proxy, where credentials are meant to propagate). -PROXY_TRANSPORT_OPTIONS = TransportOptions( - session_class=_ForwardingClientSession, - forward_incoming_headers=True, -) - - -#: Transport-level failures that can escape a backend connection attempt. -#: `Client._connect` wraps most connect failures in a ``RuntimeError("Client -#: failed to connect: ...")``, but a transport can also surface an httpx or -#: anyio stream error directly. Every proxy entry point that opens a backend -#: connection normalizes these into an ``MCPError`` so callers see a protocol -#: error instead of a raw transport exception. -_PROXY_TRANSPORT_CAUSES: tuple[type[Exception], ...] = ( - TimeoutError, - httpx2.HTTPError, - anyio.ClosedResourceError, - anyio.EndOfStream, - anyio.BrokenResourceError, -) -_PROXY_TRANSPORT_ERRORS: tuple[type[Exception], ...] = ( - RuntimeError, - *_PROXY_TRANSPORT_CAUSES, -) - - -def _has_transport_cause(error: RuntimeError) -> bool: - cause = error.__cause__ - while cause is not None: - if isinstance(cause, _PROXY_TRANSPORT_CAUSES): - return True - cause = cause.__cause__ - return False def _proxy_upstream_error(error: Exception) -> MCPError: - """Report an unreachable backend the same way however the failure arrived. - - Depending on where the dead connection is noticed, the proxy sees either - FastMCP's own `RuntimeError("Client failed to connect: ...")` or the raw - transport error underneath it. Both describe one thing — the proxy could - not reach its upstream — so both are presented identically rather than - leaking the race into the message the front client reads. - """ return MCPError( code=mcp_types.INTERNAL_ERROR, - message=str(_connection_failure(error)), + message=str(error), ) -# Request `_meta` keys that describe one negotiated MCP connection. They never -# cross the proxy: a modern backend session stamps its own negotiated values on -# every request, and a handshake-era backend must not receive them at all. -_CONNECTION_META_KEYS = frozenset( - { - mcp_types.PROTOCOL_VERSION_META_KEY, - mcp_types.CLIENT_INFO_META_KEY, - mcp_types.CLIENT_CAPABILITIES_META_KEY, - } -) - - -def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None: - """Frontend request metadata that may cross onto the backend connection. - - This is the proxy's one sanctioned read of the inbound request's `_meta`: - progress tokens, tracing, task, and application metadata pass through, - while connection-owned keys (`_CONNECTION_META_KEYS`) are dropped because - they describe the frontend connection, not the backend one. - """ - request_context = ctx.request_context if ctx is not None else None - if request_context is None or not request_context.meta: - return None - forwarded = { - key: value - for key, value in request_context.meta.items() - if key not in _CONNECTION_META_KEYS - } - return forwarded or None - - -def _forwardable_server_meta(meta: dict[str, Any] | None) -> dict[str, Any]: - """Backend result metadata that may cross onto the frontend connection.""" - return { - key: value - for key, value in (meta or {}).items() - if key not in _CONNECTION_META_KEYS and key != mcp_types.SERVER_INFO_META_KEY - } - - -def _session_request_meta( - meta: dict[str, Any] | None, -) -> mcp_types.RequestParamsMeta | None: - """Adapt forwardable metadata for a direct backend-session call. - - Direct session calls bypass the high-level client mixins, so trace context - is injected here, matching what the mixins do on the legacy client paths. - """ - return cast( - "mcp_types.RequestParamsMeta | None", inject_trace_context(meta) or None - ) - - -async def _relay_read_resource( - client: Client, uri: str, ctx: Context | None -) -> ( - list[mcp_types.TextResourceContents | mcp_types.BlobResourceContents] - | mcp_types.InputRequiredResult -): - """Read a backend resource, surfacing a guard ask rather than driving it. - - Mirrors `ProxyTool.run`: on a modern backend the low-level session is used - so an `InputRequiredResult` (SEP-2322) comes back as a result for the parent - to forward, instead of the high-level client trying to answer it here — the - proxy has no back-channel to the real user, so driving it fails outright. - The inbound request's continuation state travels down so the backend guard - sees the client's answers on its own `ctx.input_responses`. - """ - meta = _forwardable_request_meta(ctx) - if client.protocol_version not in MODERN_PROTOCOL_VERSIONS: - return await client.read_resource(uri, meta=meta) - result = await client._await_with_session_monitoring( - client.session.read_resource( - uri, - meta=_session_request_meta(meta), - input_responses=ctx.input_responses if ctx else None, - request_state=ctx.request_state if ctx else None, - allow_input_required=True, - ) - ) - if isinstance(result, mcp_types.InputRequiredResult): - return result - return list(result.contents) - - def _stash_proxy_request_context(client: Client, ctx: Context) -> None: """Stash the proxy's ``RequestContext`` on a ``ProxyClient`` before a backend call. @@ -255,16 +94,7 @@ def _stash_proxy_request_context(client: Client, ctx: Context) -> None: class ProxyInitializeMiddleware(Middleware): - """Deprecated middleware for forwarding instructions during initialization.""" - def __init__(self, proxy: FastMCPProxy) -> None: - warnings.warn( - "`ProxyInitializeMiddleware` is deprecated and will be removed in a " - "future release. `FastMCPProxy` now installs " - "`ProxyMetadataMiddleware` automatically.", - FastMCPDeprecationWarning, - stacklevel=2, - ) self.proxy = proxy async def on_initialize( @@ -286,24 +116,22 @@ class ProxyInitializeMiddleware(Middleware): ctx._fastmcp, ) async with client: - # Entering the context already ran connect-time negotiation. - # `initialize()` returns the handshake result on a legacy backend, - # but raises on a modern (server/discover) backend, which has no - # InitializeResult. That mismatch only arises when an explicit - # `mode=` pins the backend to a different era than this legacy - # front (the era-mirroring default keeps the two in lockstep, so - # a legacy front always reaches a legacy backend here). Skip the - # handshake-only call when the backend negotiated the modern era. - if client.protocol_version not in MODERN_PROTOCOL_VERSIONS: - await client.initialize() - # Capture the upstream's instructions while the session is - # live; `initialize_result` clears once the context exits. - init_result = client.initialize_result - if init_result is not None: - upstream_instructions = init_result.instructions + await client.initialize() + # Capture the upstream's instructions while the session is live; + # `initialize_result` clears once the client context exits. + init_result = client.initialize_result + if init_result is not None: + upstream_instructions = init_result.instructions except MCPError: raise - except _PROXY_TRANSPORT_ERRORS as error: + except ( + RuntimeError, + TimeoutError, + httpx2.HTTPError, + anyio.ClosedResourceError, + anyio.EndOfStream, + anyio.BrokenResourceError, + ) as error: raise _proxy_upstream_error(error) from error result = await call_next(context) @@ -390,72 +218,29 @@ class ProxyTool(Tool): async with client: ctx = context or get_context() _stash_proxy_request_context(client, ctx) - # Forward the inbound request's hop-safe `_meta` (trace - # context, progress token, etc.) to the backend. Task - # submission is a first-class params field rather than context - # state, so there is no separate task-metadata injection here. - meta = _forwardable_request_meta(ctx) + # Forward the inbound request's `_meta` block (trace context, + # version, etc.) to the backend. In SDK v2 the request context + # exposes the lifted `_meta` dict directly; task submission is a + # first-class params field rather than context state, so there + # is no separate task-metadata injection here. + req_ctx = ctx.request_context + meta: dict[str, Any] | None = ( + dict(req_ctx.meta) if req_ctx is not None and req_ctx.meta else None + ) - if client.protocol_version in MODERN_PROTOCOL_VERSIONS: - # Modern backend: call the session directly (not - # `call_tool_mcp`, which would *drive* a multi-round-trip ask - # to completion on this proxy). A guard tool's - # `InputRequiredResult` (SEP-2322) must instead surface as a - # result so the parent's middleware and wire seam own the - # round. Forward the inbound request's continuation state - # down so the backend guard tool sees the client's answers - # on its own `ctx.input_responses` / `ctx.request_state`. - request_meta = _session_request_meta(meta) - # SEP-2243: a modern backend rejects a `tools/call` whose - # `x-mcp-header` argument is not mirrored into an `Mcp-Param-*` - # header. The SDK client emits those headers only for tools it - # has listed (it caches the annotation map on `list_tools`), - # but a proxied call goes straight to `call_tool` on a fresh - # session. Seed the session's map from the backend tool's - # advertised schema so the header is emitted and the call is - # accepted; an unannotated schema yields an empty map and no - # headers, matching the client's own behavior. - header_map = x_mcp_header_map(self.parameters) - if header_map: - client.session._x_mcp_header_maps[backend_name] = header_map - result = await client._await_with_session_monitoring( - client.session.call_tool( - name=backend_name, - arguments=arguments, - meta=request_meta, - # Forward upstream progress the same way the legacy - # `call_tool_mcp` path does — without this handler a - # backend tool's `ctx.report_progress()` is dropped - # on modern proxy calls. - progress_callback=client._progress_handler, - input_responses=ctx.input_responses, - request_state=ctx.request_state, - allow_input_required=True, - ) - ) - # A backend ask round-trips into an InputRequiredToolResult - # so the parent's middleware observes it and the parent's - # wire handler unwraps it (era-gated on the parent's own - # connection). - if isinstance(result, mcp_types.InputRequiredResult): - return InputRequiredToolResult(result) - tool_result = cast("mcp_types.CallToolResult", result) - else: - # Legacy backend: the multi-round-trip result type does not - # exist there, so keep the original path. - tool_result = await client.call_tool_mcp( - name=backend_name, arguments=arguments, meta=meta - ) + result = await client.call_tool_mcp( + name=backend_name, arguments=arguments, meta=meta + ) # Pass an upstream error result through faithfully rather than # collapsing it into a raised ToolError — this preserves the # backend's content (including non-text and structured content), # and the client still raises on isError by default. # Preserve backend's meta (includes task metadata for background tasks) return ToolResult( - content=tool_result.content, - structured_content=tool_result.structured_content, - meta=tool_result.meta, - is_error=tool_result.is_error, + content=result.content, + structured_content=result.structured_content, + meta=result.meta, + is_error=result.is_error, ) def get_span_attributes(self) -> dict[str, Any]: @@ -534,12 +319,9 @@ class ProxyResource(Resource): ) as span: span.set_attribute("fastmcp.provider.type", "ProxyProvider") client = await self._get_client() - ctx = get_context() async with client: - _stash_proxy_request_context(client, ctx) - result = await _relay_read_resource(client, backend_uri, ctx) - if isinstance(result, mcp_types.InputRequiredResult): - return InputRequiredResourceResult(result) + _stash_proxy_request_context(client, get_context()) + result = await client.read_resource(backend_uri) if not result: raise ResourceError( f"Remote server returned empty content for {backend_uri}" @@ -635,28 +417,9 @@ class ProxyTemplate(ResourceTemplate): backend_template = self._backend_uri_template or self.uri_template parameterized_uri = expand_uri_template(backend_template, params) client = await self._get_client() - ctx = context or get_context() async with client: - _stash_proxy_request_context(client, ctx) - result = await _relay_read_resource(client, parameterized_uri, ctx) - - if isinstance(result, mcp_types.InputRequiredResult): - # The backend template asked for input. `InputRequiredResourceResult` - # is a `ResourceResult`, so caching it on the returned resource lets - # the ask ride the ordinary read path out to the parent's wire - # handler, which unwraps it. - return ProxyResource( - client_factory=self._client_factory, - uri=parameterized_uri, - name=self.name, - title=self.title, - description=self.description, - mime_type=self.mime_type or "text/plain", - icons=self.icons, - meta=self.meta, - tags=get_fastmcp_metadata(self.meta).get("tags", []), - _cached_content=InputRequiredResourceResult(result), - ) + _stash_proxy_request_context(client, context or get_context()) + result = await client.read_resource(parameterized_uri) if not result: raise ResourceError( @@ -773,28 +536,9 @@ class ProxyPrompt(Prompt): ) as span: span.set_attribute("fastmcp.provider.type", "ProxyProvider") client = await self._get_client() - ctx = get_context() async with client: - _stash_proxy_request_context(client, ctx) - meta = _forwardable_request_meta(ctx) - if client.protocol_version in MODERN_PROTOCOL_VERSIONS: - # See `_relay_read_resource`: surface a backend guard's ask - # instead of trying to answer it inside the proxy. - raw = await client._await_with_session_monitoring( - client.session.get_prompt( - backend_name, - arguments, - meta=_session_request_meta(meta), - input_responses=ctx.input_responses if ctx else None, - request_state=ctx.request_state if ctx else None, - allow_input_required=True, - ) - ) - if isinstance(raw, mcp_types.InputRequiredResult): - return InputRequiredPromptResult(raw) - result = raw - else: - result = await client.get_prompt(backend_name, arguments, meta=meta) + _stash_proxy_request_context(client, get_context()) + result = await client.get_prompt(backend_name, arguments) # Convert GetPromptResult to PromptResult, preserving meta from result # (not the static prompt meta which includes fastmcp tags) # Convert PromptMessages to Messages @@ -865,8 +609,8 @@ class ProxyProvider(Provider): mcp = FastMCP("Proxy Server") mcp.add_provider(proxy) - # Can also add with a namespace - mcp.add_provider(proxy, namespace="remote") + # Can also add with namespace + mcp.add_provider(proxy.with_namespace("remote")) ``` """ @@ -918,8 +662,6 @@ class ProxyProvider(Provider): tools = [] else: raise - except _PROXY_TRANSPORT_ERRORS as error: - raise _proxy_upstream_error(error) from error self._tools_cache = _CacheEntry(tools, time.monotonic()) return tools @@ -938,54 +680,6 @@ class ProxyProvider(Provider): return None return max(matching, key=version_sort_key) - async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None: - """Resolve an identity against the remote listing. - - The base implementation looks the tool up by its registered name, - which assumes the name survived to here. Across a proxy it need not: - a backend that mounts its app under a namespace advertises - ``crm_save``, and nothing named ``save`` was ever listed. Matching on - the identity carried in meta is what the identity is for. - - A remote that mounts one app twice sends back two tools claiming one - identity, exactly as a local composition would. That is refused here - on the same terms ``AggregateProvider`` refuses it, so a duplicated - app is caught wherever it is composed rather than only nearby. - """ - from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY - - cache = self._tools_cache - if cache is None or not cache.is_fresh(self._cache_ttl): - await self._list_tools() - cache = self._tools_cache - assert cache is not None - - matches: list[Tool] = [] - for tool in cache.items: - meta = tool.meta or {} - fastmcp_meta = meta.get("fastmcp") - ui_meta = meta.get("ui") - visibility = ( - ui_meta.get("visibility", []) if isinstance(ui_meta, dict) else [] - ) - if ( - isinstance(fastmcp_meta, dict) - and fastmcp_meta.get(TOOL_HASH_META_KEY) == tool_hash - and "app" in visibility - ): - matches.append(tool) - - if not matches: - return None - distinct = {tool.name for tool in matches} - if len(distinct) > 1: - raise ToolError( - f"Ambiguous app tool {tool_name!r}: {len(distinct)} components share " - f"the identity {tool_hash!r}. The same app is composed more than " - f"once, so this call cannot be routed to a single tool." - ) - return max(matches, key=version_sort_key) - # ------------------------------------------------------------------------- # Resource methods # ------------------------------------------------------------------------- @@ -1005,8 +699,6 @@ class ProxyProvider(Provider): resources = [] else: raise - except _PROXY_TRANSPORT_ERRORS as error: - raise _proxy_upstream_error(error) from error self._resources_cache = _CacheEntry(resources, time.monotonic()) return resources @@ -1044,8 +736,6 @@ class ProxyProvider(Provider): templates = [] else: raise - except _PROXY_TRANSPORT_ERRORS as error: - raise _proxy_upstream_error(error) from error self._templates_cache = _CacheEntry(templates, time.monotonic()) return templates @@ -1083,8 +773,6 @@ class ProxyProvider(Provider): prompts = [] else: raise - except _PROXY_TRANSPORT_ERRORS as error: - raise _proxy_upstream_error(error) from error self._prompts_cache = _CacheEntry(prompts, time.monotonic()) return prompts @@ -1120,198 +808,11 @@ class ProxyProvider(Provider): # because client cleanup is handled per-request -@dataclass(frozen=True) -class _UpstreamServerMetadata: - instructions: str | None - server_info: mcp_types.Implementation | None - meta: dict[str, Any] - - @classmethod - def from_result( - cls, - result: mcp_types.InitializeResult | mcp_types.DiscoverResult, - server_info: mcp_types.Implementation | None, - ) -> _UpstreamServerMetadata: - """Detach forwarded values from the backend session's adopted result.""" - return cls( - instructions=result.instructions, - server_info=( - server_info.model_copy(deep=True) if server_info is not None else None - ), - meta=deepcopy(result.meta or {}), - ) - - @classmethod - def from_client(cls, client: Client) -> _UpstreamServerMetadata | None: - result = client.session.initialize_result or client.session.discover_result - if result is None: - return None - return cls.from_result(result, client.session.server_info) - - @classmethod - def from_discover(cls, result: mcp_types.DiscoverResult) -> _UpstreamServerMetadata: - raw_server_info = (result.meta or {}).get(mcp_types.SERVER_INFO_META_KEY) - try: - server_info = ( - mcp_types.Implementation.model_validate(raw_server_info) - if raw_server_info is not None - else None - ) - except ValidationError: - server_info = None - return cls.from_result(result, server_info) - - -class ProxyMetadataMiddleware(Middleware): - """Forward optional server metadata from a ``ProxyProvider`` backend. - - Instructions and namespaced metadata are forwarded with frontend values - taking precedence. Protocol versions, capabilities, cache policy, and result - type are never copied from the backend. ``identity`` controls whether server - identity remains the gateway's or uses the backend's when available. - """ - - def __init__( - self, - provider: ProxyProvider, - *, - identity: ProxyIdentity = "proxy", - ) -> None: - if identity not in ("proxy", "upstream"): - raise ValueError("identity must be 'proxy' or 'upstream'") - self.provider = provider - self.identity = identity - - async def _read_connected(self, client: Client) -> _UpstreamServerMetadata | None: - """Read metadata without changing the client's adopted negotiation state.""" - if client.mode in MODERN_PROTOCOL_VERSIONS and client.prior_discover is None: - # An exact pin adopts a synthetic result without probing. Read the - # real result directly, but do not adopt it into this borrowed session. - raw = await client.session.send_discover(client.mode) - result_type = raw.get("resultType") - if ( - isinstance(result_type, str) - and result_type not in mcp_types.CORE_RESULT_TYPES - ): - return None - try: - result = mcp_types.DiscoverResult.model_validate(raw) - except ValidationError as error: - logger.debug("Could not read upstream server metadata: %r", error) - return None - return _UpstreamServerMetadata.from_discover(result) - return _UpstreamServerMetadata.from_client(client) - - async def _read_upstream( - self, client: Client, context: Context | None - ) -> _UpstreamServerMetadata | None: - if context is not None: - _stash_proxy_request_context(client, context) - - try: - if client.is_connected(): - return await self._read_connected(client) - async with client: - return await self._read_connected(client) - except (MCPError, *_PROXY_TRANSPORT_ERRORS) as error: - if isinstance(error, RuntimeError) and not _has_transport_cause(error): - raise - logger.debug("Could not read upstream server metadata: %r", error) - return None - - def _updates( - self, - result: mcp_types.InitializeResult | mcp_types.DiscoverResult, - upstream: _UpstreamServerMetadata, - ) -> dict[str, Any]: - meta = _forwardable_server_meta(upstream.meta) - meta.update(result.meta or {}) - - updates: dict[str, Any] = {"meta": meta or None} - if result.instructions is None and upstream.instructions is not None: - updates["instructions"] = upstream.instructions - if self.identity == "upstream" and upstream.server_info is not None: - if isinstance(result, mcp_types.InitializeResult): - updates["server_info"] = upstream.server_info - else: - meta[mcp_types.SERVER_INFO_META_KEY] = upstream.server_info.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - updates["meta"] = meta - return updates - - async def on_initialize( - self, - context: MiddlewareContext[mcp_types.InitializeRequest], - call_next: CallNext[ - mcp_types.InitializeRequest, mcp_types.InitializeResult | None - ], - ) -> mcp_types.InitializeResult | None: - # Factory errors must occur before the legacy response is committed. - client = await self.provider._get_client() - result = await call_next(context) - if result is None: - return None - upstream = await self._read_upstream(client, context.fastmcp_context) - if upstream is None: - return result - return result.model_copy(update=self._updates(result, upstream)) - - async def on_discover( - self, - context: MiddlewareContext[mcp_types.DiscoverRequest], - call_next: CallNext[ - mcp_types.DiscoverRequest, - mcp_types.DiscoverResult | dict[str, Any], - ], - ) -> mcp_types.DiscoverResult | dict[str, Any]: - result = await call_next(context) - if not isinstance(result, mcp_types.DiscoverResult): - return result - client = await self.provider._get_client() - upstream = await self._read_upstream(client, context.fastmcp_context) - if upstream is None: - return result - return result.model_copy(update=self._updates(result, upstream)) - - # ----------------------------------------------------------------------------- # Factory Functions # ----------------------------------------------------------------------------- -def _mirror_front_era_mode() -> str | None: - """Return the backend connect ``mode`` that mirrors the front connection's era. - - A proxy is a server on its front and a client on its back. The two protocol - eras have mutually exclusive interaction models on a single session, so the - whole chain must speak one era end-to-end: a modern front must reach a modern - backend (a guard tool's `InputRequiredResult` round-trips), and a handshake - front must reach a handshake backend (server-initiated sampling / elicitation - / roots push-forwarding works). Rather than pin its own era, the proxy speaks - on its back whatever era was negotiated on its front. - - Reads the negotiated protocol version from the active front request context: - - - modern front → that exact version, so the backend negotiates the same era - (pinning the version rather than ``"auto"`` makes the eras truly match). - - handshake front → ``"legacy"``. - - no request context (e.g. proxy construction before any request) → ``None``, - leaving the factory's configured default mode in place. - """ - try: - ctx = get_context() - except RuntimeError: - return None - rc = ctx.request_context - if rc is None: - return None - version = rc.protocol_version - if version in MODERN_PROTOCOL_VERSIONS: - return version - return "legacy" - - def _create_client_factory( target: ( Client[ClientTransportT] @@ -1324,8 +825,6 @@ def _create_client_factory( | dict[str, Any] | str ), - *, - mode: str | None = None, ) -> ClientFactoryT: """Create a client factory from the given target. @@ -1337,22 +836,13 @@ def _create_client_factory( if isinstance(target, Client): client = target - def as_proxy_backend(c: Client) -> Client: - """Apply proxy connection settings to a copy we own. + # Plain Clients used as proxy backends also need header forwarding, + # same as ProxyClient (which sets this in __init__). + from fastmcp.client.transports.http import StreamableHttpTransport + from fastmcp.client.transports.sse import SSETransport - The caller handed us their Client; configuring it in place would - change how their own connections behave, including whether their - credentials get forwarded upstream. - """ - fresh = c.new() - # The caller chose this client's era, so a multi-server MCPConfig - # target's mounted backend legs should negotiate it too rather than - # stopping at the composite router (see - # `TransportOptions.backend_mode`). - fresh._transport_options = replace( - PROXY_TRANSPORT_OPTIONS, backend_mode=fresh.mode - ) - return fresh + if isinstance(client.transport, StreamableHttpTransport | SSETransport): + client.transport.forward_incoming_headers = True if client.is_connected() and type(client) is ProxyClient: logger.info( @@ -1361,68 +851,31 @@ def _create_client_factory( ) def fresh_client_factory() -> Client: - return as_proxy_backend(client) + return client.new() return fresh_client_factory if client.is_connected(): logger.info( "Proxy detected connected client - reusing existing session for all requests. " - "This may cause context mixing in concurrent scenarios, and the session's " - "existing settings apply, so backend results are validated against their " - "declared output schema rather than relayed as-is. Pass a disconnected " - "client to avoid both." + "This may cause context mixing in concurrent scenarios." ) - # The caller's session is already built, so there are no connection - # settings left to apply — proxy options only take effect at connect - # time. Reuse is opt-in via passing an already-connected client. def reuse_client_factory() -> Client: return client return reuse_client_factory def fresh_client_factory() -> Client: - return as_proxy_backend(client) + return client.new() return fresh_client_factory else: - # target is not a Client, so it's compatible with ProxyClient.__init__. - # - # With no explicit mode, the backend MIRRORS the front connection's - # negotiated era per request (see `_mirror_front_era_mode`): a fresh - # client is built for each request and its mode is set from the front - # era, so the whole chain speaks one era end-to-end. Because every - # request gets its own client whose mode is derived at call time, front - # connections of different eras never share a backend session — there is - # no era to bleed across the (metadata-only) provider caches. - # - # An explicit mode pins the backend era regardless of the front. This - # breaks era-consistency and is only appropriate when the backend speaks - # a single era; the mismatch surfaces through the normal era gates. - explicit_mode = mode is not None - client_kwargs: dict[str, Any] = {"mode": mode} if explicit_mode else {} - base_client = ProxyClient(cast(Any, target), **client_kwargs) + # target is not a Client, so it's compatible with ProxyClient.__init__ + base_client = ProxyClient(cast(Any, target)) def proxy_client_factory() -> Client: - fresh = base_client.new() - backend_mode = mode - if not explicit_mode: - backend_mode = _mirror_front_era_mode() - if backend_mode is not None: - fresh.mode = backend_mode - if backend_mode is not None: - # A multi-server MCPConfig target reaches its real backends - # through proxies mounted on a composite router, so setting the - # era on this client alone would stop at the router. Carry the - # era down to those backend legs too (see - # `TransportOptions.backend_mode`), resolved here — at the - # moment a client is built for this request — so it tracks the - # front era rather than whatever was true at construction. - fresh._transport_options = replace( - PROXY_TRANSPORT_OPTIONS, backend_mode=backend_mode - ) - return fresh + return base_client.new() return proxy_client_factory @@ -1456,7 +909,6 @@ class FastMCPProxy(FastMCP): *, client_factory: ClientFactoryT, provider_error_strategy: ProviderErrorStrategy = "warn", - identity: ProxyIdentity = "proxy", **kwargs, ): """Initialize the proxy server. @@ -1471,17 +923,14 @@ class FastMCPProxy(FastMCP): provider_error_strategy: How provider errors should affect aggregate operations. Defaults to ``"warn"`` for compatibility; use ``"raise"`` when the proxy should surface upstream failures. - identity: Whether clients see the proxy's server identity or the - upstream server's when available. Defaults to ``"proxy"`` - for compatibility. **kwargs: Additional settings for the FastMCP server. """ super().__init__(**kwargs) self.provider_error_strategy = provider_error_strategy self.client_factory = client_factory - provider = ProxyProvider(client_factory) + provider: Provider = ProxyProvider(client_factory) self.add_provider(provider) - self.middleware.append(ProxyMetadataMiddleware(provider, identity=identity)) + self.middleware.append(ProxyInitializeMiddleware(self)) self._setup_proxy_ping_handler() async def _get_client(self) -> Client: @@ -1513,19 +962,9 @@ class FastMCPProxy(FastMCP): async def default_proxy_roots_handler( context: ServerRequestContext[Any, Any], ) -> RootsList: - """Forward list roots request from remote server to proxy's connected clients. - - A handshake-era backend can still issue `roots/list`, and the proxy is that - backend's client, so it relays the request onto its own front session. This - reaches the wire through the SDK session rather than a `Context` method: - `ctx.list_roots()` is not part of FastMCP's server-authoring API, because - SEP-2577 removed server-initiated requests from the modern protocol. The - relay exists only for handshake-era interop on both legs. - """ + """Forward list roots request from remote server to proxy's connected clients.""" ctx = get_context() - # Deprecated upstream in SDK v2; the handshake-era relay is the one caller. - result = await ctx.session.list_roots() # ty: ignore[deprecated] - return result.roots + return await ctx.list_roots() async def default_proxy_sampling_handler( @@ -1533,27 +972,16 @@ async def default_proxy_sampling_handler( params: mcp_types.CreateMessageRequestParams, context: ServerRequestContext[Any, Any], ) -> mcp_types.CreateMessageResult: - """Forward sampling request from remote server to proxy's connected clients. - - Relays through the SDK session for the same reason as - `default_proxy_roots_handler`: server-initiated sampling is not part of - FastMCP's server-authoring API, and this path only ever runs when both legs - of the proxy speak the handshake era. - """ + """Forward sampling request from remote server to proxy's connected clients.""" ctx = get_context() - # Deprecated upstream in SDK v2; the handshake-era relay is the one caller. - result = await ctx.session.create_message( # ty: ignore[deprecated] - messages=list(messages), + result = await ctx.sample( + list(messages), system_prompt=params.system_prompt, temperature=params.temperature, max_tokens=params.max_tokens, model_preferences=params.model_preferences, - related_request_id=ctx.origin_request_id, ) - text = ( - result.content.text if isinstance(result.content, mcp_types.TextContent) else "" - ) - content = mcp_types.TextContent(type="text", text=text) + content = mcp_types.TextContent(type="text", text=result.text or "") return mcp_types.CreateMessageResult( role="assistant", model="fastmcp-client", @@ -1654,7 +1082,12 @@ def _restore_request_context( def _make_restoring_handler(handler: Callable, rc_ref: list[Any]) -> Callable: - """Wrap a proxy handler to restore request_ctx before delegating.""" + """Wrap a proxy handler to restore request_ctx before delegating. + + The wrapper is a plain ``async def`` so it passes + ``inspect.isfunction()`` checks in handler registration paths + (e.g., ``create_roots_callback``). + """ async def wrapper(*args: Any, **kwargs: Any) -> Any: _restore_request_context(rc_ref) @@ -1688,12 +1121,6 @@ class ProxyClient(Client[ClientTransportT]): _proxy_rc_ref: list[Any] _proxy_restoring_handler_keys: set[str] - # A proxy forwards calls; it must not advertise task support to its backend. - # Proxied tools run synchronously (forbidden mode), and the proxy has no path - # to drive a backend task on the front connection's behalf, so the internal - # tasks client extension is not folded into a proxy's backend client. - _auto_internal_extensions: bool = False - def __init__( self, transport: ClientTransportT @@ -1708,23 +1135,6 @@ class ProxyClient(Client[ClientTransportT]): ): if "name" not in kwargs: kwargs["name"] = self.generate_name() - # ProxyClient itself defaults to the handshake era when constructed - # directly: a single proxy session can only be one era, and handshake - # keeps the server-initiated push forwarding (sampling / elicitation / - # roots, via the handlers installed below) that proxies rely on. When a - # proxy is created from a non-Client target (`create_proxy(target)` / - # `_create_client_factory`) with no explicit mode, the factory instead - # MIRRORS the front connection's negotiated era onto this client per - # request, so the whole chain speaks one era end-to-end. An explicit - # `mode=` (e.g. `create_proxy(target, mode="auto")`) pins the era and - # overrides mirroring. The eras are mutually exclusive per session. - # - # The handshake default is pinned explicitly rather than inherited from - # `Client`, whose own default is `"auto"`: mirroring only applies when - # there is a front request to mirror, so this is the fallback for a - # directly-constructed ProxyClient, and it must not drift with the - # client default. - kwargs.setdefault("mode", "legacy") # Install context-restoring handler wrappers BEFORE super().__init__ # registers them with the Client's session kwargs. self._proxy_rc_ref = [None] @@ -1741,7 +1151,14 @@ class ProxyClient(Client[ClientTransportT]): self._proxy_restoring_handler_keys.add(key) super().__init__(transport=transport, **kwargs) # ty: ignore[no-matching-overload] - self._transport_options = PROXY_TRANSPORT_OPTIONS + # Enable forwarding of inbound HTTP headers (e.g. authorization) to + # the upstream server. This is only appropriate for proxy clients, + # where the caller's credentials should be propagated. + from fastmcp.client.transports.http import StreamableHttpTransport + from fastmcp.client.transports.sse import SSETransport + + if isinstance(self.transport, StreamableHttpTransport | SSETransport): + self.transport.forward_incoming_headers = True def _bind_restoring_handlers(self) -> None: if "roots" in self._proxy_restoring_handler_keys: diff --git a/fastmcp_slim/fastmcp/server/providers/skills/__init__.py b/fastmcp_slim/fastmcp/server/providers/skills/__init__.py index 5945944b5..b15c1c636 100644 --- a/fastmcp_slim/fastmcp/server/providers/skills/__init__.py +++ b/fastmcp_slim/fastmcp/server/providers/skills/__init__.py @@ -40,6 +40,10 @@ from fastmcp.server.providers.skills.vendor_providers import ( ) +# Backwards compatibility alias +SkillsProvider = SkillsDirectoryProvider + + __all__ = [ "ClaudeSkillsProvider", "CodexSkillsProvider", @@ -50,5 +54,6 @@ __all__ = [ "OpenCodeSkillsProvider", "SkillProvider", "SkillsDirectoryProvider", + "SkillsProvider", # Backwards compatibility alias "VSCodeSkillsProvider", ] diff --git a/fastmcp_slim/fastmcp/server/providers/skills/_common.py b/fastmcp_slim/fastmcp/server/providers/skills/_common.py index 340d289e5..d0e1177a5 100644 --- a/fastmcp_slim/fastmcp/server/providers/skills/_common.py +++ b/fastmcp_slim/fastmcp/server/providers/skills/_common.py @@ -39,8 +39,6 @@ def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: Returns: Tuple of (frontmatter dict, remaining content) """ - content = content.removeprefix("\ufeff") - if not content.startswith("---"): return {}, content diff --git a/fastmcp_slim/fastmcp/server/providers/skills/skill_provider.py b/fastmcp_slim/fastmcp/server/providers/skills/skill_provider.py index 41a8c766b..0550e9042 100644 --- a/fastmcp_slim/fastmcp/server/providers/skills/skill_provider.py +++ b/fastmcp_slim/fastmcp/server/providers/skills/skill_provider.py @@ -7,7 +7,6 @@ import mimetypes from collections.abc import Sequence from pathlib import Path from typing import Any, Literal, cast -from urllib.parse import quote, unquote from mcp.shared.path_security import PathEscapeError, safe_join from pydantic import AnyUrl @@ -98,12 +97,16 @@ class SkillFileTemplate(ResourceTemplate): else: return full_path.read_bytes() - async def _read( + async def _read( # type: ignore[override] self, uri: str, params: dict[str, Any], - ) -> ResourceResult: - """Server entry point - read file directly without creating ephemeral resource.""" + task_meta: Any = None, + ) -> ResourceResult: # ty:ignore[invalid-method-override] + """Server entry point - read file directly without creating ephemeral resource. + + Note: task_meta is ignored - this template doesn't support background tasks. + """ # Call read() directly and convert to ResourceResult result = await self.read(arguments=params) return self.convert_result(result) @@ -284,9 +287,7 @@ class SkillProvider(Provider): # Main skill file resources.append( SkillResource( - uri=AnyUrl( - f"skill://{skill.name}/{quote(self._main_file_name, safe='/')}" - ), + uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"), name=f"{skill.name}/{self._main_file_name}", description=skill.description, mime_type="text/markdown", @@ -317,9 +318,7 @@ class SkillProvider(Provider): mime_type, _ = mimetypes.guess_type(file_info.path) resources.append( SkillFileResource( - uri=AnyUrl( - f"skill://{skill.name}/{quote(file_info.path, safe='/')}" - ), + uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"), name=f"{skill.name}/{file_info.path}", description=f"File from {skill.name} skill", mime_type=mime_type or "application/octet-stream", @@ -348,7 +347,6 @@ class SkillProvider(Provider): skill_name, file_path = parts if skill_name != skill.name: return None - file_path = unquote(file_path) if file_path == "_manifest": return SkillResource( diff --git a/fastmcp_slim/fastmcp/server/sampling/__init__.py b/fastmcp_slim/fastmcp/server/sampling/__init__.py new file mode 100644 index 000000000..392326d35 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/sampling/__init__.py @@ -0,0 +1,10 @@ +"""Sampling module for FastMCP servers.""" + +from fastmcp.server.sampling.run import SampleStep, SamplingResult +from fastmcp.server.sampling.sampling_tool import SamplingTool + +__all__ = [ + "SampleStep", + "SamplingResult", + "SamplingTool", +] diff --git a/fastmcp_slim/fastmcp/server/sampling/run.py b/fastmcp_slim/fastmcp/server/sampling/run.py new file mode 100644 index 000000000..0ce5524fb --- /dev/null +++ b/fastmcp_slim/fastmcp/server/sampling/run.py @@ -0,0 +1,834 @@ +"""Sampling types and helper functions for FastMCP servers.""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Generic, Literal, cast + +import anyio +from mcp_types import ( + ClientCapabilities, + CreateMessageResult, + CreateMessageResultWithTools, + ModelHint, + ModelPreferences, + SamplingCapability, + SamplingMessage, + SamplingMessageContentBlock, + SamplingToolsCapability, + TextContent, + ToolChoice, + ToolResultContent, + ToolUseContent, +) +from mcp_types import CreateMessageRequestParams as SamplingParams +from mcp_types import Tool as SDKTool +from opentelemetry.trace import SpanKind, Status, StatusCode +from pydantic import ValidationError +from typing_extensions import TypeVar + +from fastmcp import settings +from fastmcp.exceptions import ToolError +from fastmcp.server.sampling.sampling_tool import SamplingTool +from fastmcp.telemetry import get_tracer +from fastmcp.tools.function_tool import FunctionTool +from fastmcp.tools.tool_transform import TransformedTool +from fastmcp.utilities.async_utils import gather +from fastmcp.utilities.json_schema import compress_schema +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import get_cached_typeadapter + +logger = get_logger(__name__) + +if TYPE_CHECKING: + from fastmcp.server.context import Context + +ResultT = TypeVar("ResultT") + +# Maximum number of consecutive final_response validation retries (not +# counting the initial attempt) before aborting. Total attempts = N + 1. +_MAX_VALIDATION_RETRIES = 3 + +# Simplified tool choice type - just the mode string instead of the full MCP object +ToolChoiceOption = Literal["auto", "required", "none"] + +# How many times we retry when the LLM returns text instead of calling final_response +_MAX_TEXT_RESPONSE_RETRIES = 3 + + +@dataclass +class SamplingResult(Generic[ResultT]): + """Result of a sampling operation. + + Attributes: + text: The text representation of the result (raw text or JSON for structured). + result: The typed result (str for text, parsed object for structured output). + history: All messages exchanged during sampling. + """ + + text: str | None + result: ResultT + history: list[SamplingMessage] + + +@dataclass +class SampleStep: + """Result of a single sampling call. + + Represents what the LLM returned in this step plus the message history. + """ + + response: CreateMessageResult | CreateMessageResultWithTools + history: list[SamplingMessage] + + @property + def is_tool_use(self) -> bool: + """True if the LLM is requesting tool execution.""" + if isinstance(self.response, CreateMessageResultWithTools): + return self.response.stop_reason == "toolUse" + return False + + @property + def text(self) -> str | None: + """Extract text from the response, if available.""" + content = self.response.content + if isinstance(content, list): + for block in content: + if isinstance(block, TextContent): + return block.text + return None + elif isinstance(content, TextContent): + return content.text + return None + + @property + def tool_calls(self) -> list[ToolUseContent]: + """Get the list of tool calls from the response.""" + content = self.response.content + if isinstance(content, list): + return [c for c in content if isinstance(c, ToolUseContent)] + elif isinstance(content, ToolUseContent): + return [content] + return [] + + +def _parse_model_preferences( + model_preferences: ModelPreferences | str | list[str] | None, +) -> ModelPreferences | None: + """Convert model preferences to ModelPreferences object.""" + if model_preferences is None: + return None + elif isinstance(model_preferences, ModelPreferences): + return model_preferences + elif isinstance(model_preferences, str): + return ModelPreferences(hints=[ModelHint(name=model_preferences)]) + elif isinstance(model_preferences, list): + if not all(isinstance(h, str) for h in model_preferences): + raise ValueError("All elements of model_preferences list must be strings.") + return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences]) + else: + raise ValueError( + "model_preferences must be one of: ModelPreferences, str, list[str], or None." + ) + + +# --- Standalone functions for sample_step() --- + + +def determine_handler_mode( + context: Context, needs_tools: bool, *, client_available: bool = True +) -> bool: + """Determine whether to use fallback handler or client for sampling. + + Args: + context: The MCP context. + needs_tools: Whether the sampling request requires tool support. + client_available: Whether the client back-channel can be reached at all. + On modern (2026-07-28) connections the server-initiated createMessage + back-channel was removed (SEP-2577), so the client can never serve a + sampling request; pass False there to force the server-side handler + path (``"fallback"`` behaves like ``"always"`` when a handler exists). + + Returns: + True if fallback handler should be used, False to use client. + + Raises: + ValueError: If client lacks required capability and no fallback configured. + """ + fastmcp = context.fastmcp + session = context.session + + # Check what capabilities the client has. On connections without a + # back-channel the client can never serve the request regardless of the + # capabilities it advertised, so treat both as unavailable. + has_sampling = client_available and session.check_client_capability( + capability=ClientCapabilities(sampling=SamplingCapability()) + ) + has_tools_capability = client_available and session.check_client_capability( + capability=ClientCapabilities( + sampling=SamplingCapability(tools=SamplingToolsCapability()) + ) + ) + + if fastmcp.sampling_handler_behavior == "always": + if fastmcp.sampling_handler is None: + raise ValueError( + "sampling_handler_behavior is 'always' but no handler configured" + ) + return True + elif fastmcp.sampling_handler_behavior == "fallback": + client_sufficient = has_sampling and (not needs_tools or has_tools_capability) + if not client_sufficient: + if fastmcp.sampling_handler is None: + if needs_tools and has_sampling and not has_tools_capability: + raise ValueError( + "Client does not support sampling with tools. " + "The client must advertise the sampling.tools capability." + ) + raise ValueError("Client does not support sampling") + return True + elif fastmcp.sampling_handler_behavior is not None: + raise ValueError( + f"Invalid sampling_handler_behavior: {fastmcp.sampling_handler_behavior!r}. " + "Must be 'always', 'fallback', or None." + ) + elif not has_sampling: + raise ValueError("Client does not support sampling") + elif needs_tools and not has_tools_capability: + raise ValueError( + "Client does not support sampling with tools. " + "The client must advertise the sampling.tools capability." + ) + + return False + + +async def call_sampling_handler( + context: Context, + messages: list[SamplingMessage], + *, + system_prompt: str | None, + temperature: float | None, + max_tokens: int, + model_preferences: ModelPreferences | str | list[str] | None, + sdk_tools: list[SDKTool] | None, + tool_choice: ToolChoice | None, +) -> CreateMessageResult | CreateMessageResultWithTools: + """Make LLM call using the fallback handler. + + Note: This function expects the caller (sample_step) to have validated that + sampling_handler is set via determine_handler_mode(). The checks below are + safeguards against internal misuse. + """ + if context.fastmcp.sampling_handler is None: + raise RuntimeError("sampling_handler is None") + if context.request_context is None: + raise RuntimeError("request_context is None") + + result = context.fastmcp.sampling_handler( + messages, + SamplingParams( + system_prompt=system_prompt, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + model_preferences=_parse_model_preferences(model_preferences), + tools=sdk_tools, + tool_choice=tool_choice, + ), + # SamplingHandler is typed against the SDK's RequestContext placeholder, + # but FastMCP hands handlers its own FastMCPRequestContext wrapper at + # runtime; the two aren't structurally related in the type system. + context.request_context, # ty: ignore[invalid-argument-type] + ) + + if inspect.isawaitable(result): + result = await result + + result = cast("str | CreateMessageResult | CreateMessageResultWithTools", result) + + # Convert string to CreateMessageResult + if isinstance(result, str): + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text=result), + model="unknown", + stop_reason="endTurn", + ) + + return result + + +async def execute_tools( + tool_calls: list[ToolUseContent], + tool_map: dict[str, SamplingTool], + mask_error_details: bool = False, + tool_concurrency: int | None = None, +) -> list[ToolResultContent]: + """Execute tool calls and return results. + + Args: + tool_calls: List of tool use requests from the LLM. + tool_map: Mapping from tool name to SamplingTool. + mask_error_details: If True, mask detailed error messages from tool execution. + When masked, only generic error messages are returned to the LLM. + Tools can explicitly raise ToolError to bypass masking when they want + to provide specific error messages to the LLM. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. + + Returns: + List of tool result content blocks in the same order as tool_calls. + """ + if tool_concurrency is not None and tool_concurrency < 0: + raise ValueError( + f"tool_concurrency must be None, 0 (unlimited), or a positive integer, " + f"got {tool_concurrency}" + ) + + async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent: + """Execute a single tool and return its result.""" + tool = tool_map.get(tool_use.name) + if tool is None: + return ToolResultContent( + type="tool_result", + tool_use_id=tool_use.id, + content=[ + TextContent( + type="text", + text=f"Error: Unknown tool '{tool_use.name}'", + ) + ], + is_error=True, + ) + + tracer = get_tracer() + span_attrs = { + "gen_ai.tool.name": tool_use.name, + "fastmcp.tool.use_id": tool_use.id, + } + with tracer.start_as_current_span( + f"sampling tool {tool_use.name}", + kind=SpanKind.INTERNAL, + attributes=span_attrs, + ) as span: + # Reapply: `attributes=span_attrs` above lets on_start hooks and + # the sampler see these values at creation time. But OTel's + # Tracer.start_span builds the span from + # `sampling_result.attributes`, not the `attributes` kwarg + # directly — a custom Sampler whose SamplingResult.attributes + # defaults to None silently drops everything we passed. + # Reapplying here (additive, can't clobber anything a sampler + # legitimately added) guarantees FastMCP's attributes survive + # regardless of sampler behavior. + if span.is_recording(): + span.set_attributes(span_attrs) + try: + result_value = await tool.run(tool_use.input) + return ToolResultContent( + type="tool_result", + tool_use_id=tool_use.id, + content=[TextContent(type="text", text=str(result_value))], + ) + except ToolError as e: + if span.is_recording(): + span.set_attribute("error.type", "tool_error") + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR, str(e))) + logger.log( + e.log_level, + f"Error calling sampling tool '{tool_use.name}'", + exc_info=True, + ) + return ToolResultContent( + type="tool_result", + tool_use_id=tool_use.id, + content=[TextContent(type="text", text=str(e))], + is_error=True, + ) + except Exception as e: + if span.is_recording(): + span.set_attribute("error.type", type(e).__qualname__) + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR, str(e))) + logger.exception(f"Error calling sampling tool '{tool_use.name}'") + if mask_error_details: + error_text = f"Error executing tool '{tool_use.name}'" + else: + error_text = f"Error executing tool '{tool_use.name}': {e}" + return ToolResultContent( + type="tool_result", + tool_use_id=tool_use.id, + content=[TextContent(type="text", text=error_text)], + is_error=True, + ) + + # Check if any tool requires sequential execution + requires_sequential = any( + tool.sequential + for tool_use in tool_calls + if (tool := tool_map.get(tool_use.name)) is not None + ) + + # Execute sequentially if required or if concurrency is None (default) + if tool_concurrency is None or requires_sequential: + tool_results: list[ToolResultContent] = [] + for tool_use in tool_calls: + result = await _execute_single_tool(tool_use) + tool_results.append(result) + return tool_results + + # Execute in parallel + if tool_concurrency == 0: + # Unlimited parallel execution + return await gather(*[_execute_single_tool(tc) for tc in tool_calls]) + else: + # Bounded parallel execution with semaphore + semaphore = anyio.Semaphore(tool_concurrency) + + async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent: + async with semaphore: + return await _execute_single_tool(tool_use) + + return await gather(*[bounded_execute(tc) for tc in tool_calls]) + + +# --- Helper functions for sampling --- + + +def prepare_messages( + messages: str | Sequence[str | SamplingMessage], +) -> list[SamplingMessage]: + """Convert various message formats to a list of SamplingMessage objects.""" + if isinstance(messages, str): + return [ + SamplingMessage( + content=TextContent(text=messages, type="text"), role="user" + ) + ] + else: + return [ + SamplingMessage(content=TextContent(text=m, type="text"), role="user") + if isinstance(m, str) + else m + for m in messages + ] + + +def prepare_tools( + tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] + | None, +) -> list[SamplingTool] | None: + """Convert tools to SamplingTool objects. + + Accepts SamplingTool instances, FunctionTool instances, TransformedTool instances, + or plain callable functions. FunctionTool and TransformedTool are converted using + from_callable_tool(), while plain functions use from_function(). + + Args: + tools: Sequence of tools to prepare. Can be SamplingTool, FunctionTool, + TransformedTool, or plain callable functions. + + Returns: + List of SamplingTool instances, or None if tools is None. + """ + if tools is None: + return None + + sampling_tools: list[SamplingTool] = [] + for t in tools: + if isinstance(t, SamplingTool): + sampling_tools.append(t) + elif isinstance(t, (FunctionTool, TransformedTool)): + sampling_tools.append(SamplingTool.from_callable_tool(t)) + elif callable(t): + sampling_tools.append(SamplingTool.from_function(t)) + else: + raise TypeError( + f"Expected SamplingTool, FunctionTool, TransformedTool, or callable, got {type(t)}" + ) + + return sampling_tools if sampling_tools else None + + +def extract_tool_calls( + response: CreateMessageResult | CreateMessageResultWithTools, +) -> list[ToolUseContent]: + """Extract tool calls from a response.""" + content = response.content + if isinstance(content, list): + return [c for c in content if isinstance(c, ToolUseContent)] + elif isinstance(content, ToolUseContent): + return [content] + return [] + + +def create_final_response_tool(result_type: type) -> SamplingTool: + """Create a synthetic 'final_response' tool for structured output. + + This tool is used to capture structured responses from the LLM. + The tool's schema is derived from the result_type. + """ + type_adapter = get_cached_typeadapter(result_type) + schema = type_adapter.json_schema() + schema = compress_schema(schema, prune_titles=True) + + # Tool parameters must be object-shaped. Wrap primitives in {"value": <schema>} + if schema.get("type") != "object": + schema = { + "type": "object", + "properties": {"value": schema}, + "required": ["value"], + } + + # The fn just returns the input as-is (validation happens in the loop) + def final_response(**kwargs: Any) -> dict[str, Any]: + return kwargs + + return SamplingTool( + name="final_response", + description=( + "Call this tool to provide your final response. " + "Use this when you have completed the task and are ready to return the result." + ), + parameters=schema, + fn=final_response, + ) + + +# --- Implementation functions for Context methods --- + + +async def sample_step_impl( + context: Context, + messages: str | Sequence[str | SamplingMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + model_preferences: ModelPreferences | str | list[str] | None = None, + tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] + | None = None, + tool_choice: ToolChoiceOption | str | None = None, + auto_execute_tools: bool = True, + mask_error_details: bool | None = None, + tool_concurrency: int | None = None, + client_available: bool = True, +) -> SampleStep: + """Implementation of Context.sample_step(). + + Make a single LLM sampling call. This is a stateless function that makes + exactly one LLM call and optionally executes any requested tools. + + When ``client_available`` is False (e.g. a modern 2026-07-28 connection with + no back-channel), the client is never used and a configured sampling handler + serves the request; the caller is responsible for raising a clear era error + when no handler can serve it. + """ + # Convert messages to SamplingMessage objects + current_messages = prepare_messages(messages) + + # Convert tools to SamplingTools + sampling_tools = prepare_tools(tools) + sdk_tools: list[SDKTool] | None = ( + [t._to_sdk_tool() for t in sampling_tools] if sampling_tools else None + ) + tool_map: dict[str, SamplingTool] = ( + {t.name: t for t in sampling_tools} if sampling_tools else {} + ) + + # Determine whether to use fallback handler or client + use_fallback = determine_handler_mode( + context, bool(sampling_tools), client_available=client_available + ) + + # Build tool choice + effective_tool_choice: ToolChoice | None = None + if tool_choice is not None: + if tool_choice not in ("auto", "required", "none"): + raise ValueError( + f"Invalid tool_choice: {tool_choice!r}. " + "Must be 'auto', 'required', or 'none'." + ) + effective_tool_choice = ToolChoice( + mode=cast(Literal["auto", "required", "none"], tool_choice) + ) + + # Effective max_tokens + effective_max_tokens = max_tokens if max_tokens is not None else 512 + + # Make the LLM call + tracer = get_tracer() + span_attrs = { + "mcp.method.name": "sampling/createMessage", + "fastmcp.server.name": context.fastmcp.name, + } + with tracer.start_as_current_span( + "sampling create_message", + kind=SpanKind.CLIENT, + attributes=span_attrs, + record_exception=False, + set_status_on_exception=False, + ) as span: + # Reapply: `attributes=span_attrs` above lets on_start hooks and the + # sampler see these values at creation time. But OTel's + # Tracer.start_span builds the span from + # `sampling_result.attributes`, not the `attributes` kwarg directly — + # a custom Sampler whose SamplingResult.attributes defaults to None + # silently drops everything we passed. Reapplying here (additive, + # can't clobber anything a sampler legitimately added) guarantees + # FastMCP's attributes survive regardless of sampler behavior. + if span.is_recording(): + span.set_attributes(span_attrs) + try: + if use_fallback: + response = await call_sampling_handler( + context, + current_messages, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=effective_max_tokens, + model_preferences=model_preferences, + sdk_tools=sdk_tools, + tool_choice=effective_tool_choice, + ) + else: + # Deprecated upstream in SDK v2 but deliberately kept per compat + # directive; removed with the multi-round-trip follow-up. + response = await context.session.create_message( # ty: ignore[deprecated] + messages=current_messages, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=effective_max_tokens, + model_preferences=_parse_model_preferences(model_preferences), + tools=sdk_tools, + tool_choice=effective_tool_choice, + related_request_id=context.origin_request_id, + ) + except Exception as e: + if span.is_recording(): + span.set_attribute("error.type", type(e).__qualname__) + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR, str(e))) + raise + + # Check if this is a tool use response + is_tool_use_response = ( + isinstance(response, CreateMessageResultWithTools) + and response.stop_reason == "toolUse" + ) + + # Always include the assistant response in history + current_messages.append(SamplingMessage(role="assistant", content=response.content)) + + # If not a tool use, return immediately + if not is_tool_use_response: + return SampleStep(response=response, history=current_messages) + + # If not executing tools, return with assistant message but no tool results + if not auto_execute_tools: + return SampleStep(response=response, history=current_messages) + + # Execute tools and add results to history + step_tool_calls = extract_tool_calls(response) + if step_tool_calls: + effective_mask = ( + mask_error_details + if mask_error_details is not None + else settings.mask_error_details + ) + tool_results: list[ToolResultContent] = await execute_tools( + step_tool_calls, + tool_map, + mask_error_details=effective_mask, + tool_concurrency=tool_concurrency, + ) + + if tool_results: + current_messages.append( + SamplingMessage( + role="user", + content=cast(list[SamplingMessageContentBlock], tool_results), + ) + ) + + return SampleStep(response=response, history=current_messages) + + +async def sample_impl( + context: Context, + messages: str | Sequence[str | SamplingMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + model_preferences: ModelPreferences | str | list[str] | None = None, + tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] + | None = None, + result_type: type[ResultT] | None = None, + mask_error_details: bool | None = None, + tool_concurrency: int | None = None, + client_available: bool = True, +) -> SamplingResult[ResultT]: + """Implementation of Context.sample(). + + Send a sampling request to the client and await the response. This method + runs to completion automatically, executing a tool loop until the LLM + provides a final text response. + + When ``client_available`` is False (e.g. a modern 2026-07-28 connection with + no back-channel), the client is never used and a configured sampling handler + serves the request; the caller is responsible for raising a clear era error + when no handler can serve it. + """ + # Safety limit to prevent infinite loops + max_iterations = 100 + + # Convert tools to SamplingTools + sampling_tools = prepare_tools(tools) + + # Handle structured output with result_type + tool_choice: str | None = None + if result_type is not None and result_type is not str: + final_response_tool = create_final_response_tool(result_type) + sampling_tools = list(sampling_tools) if sampling_tools else [] + sampling_tools.append(final_response_tool) + + # Always require tool calls when result_type is set - the LLM must + # eventually call final_response (text responses are not accepted) + tool_choice = "required" + + # Convert messages for the loop + current_messages: str | Sequence[str | SamplingMessage] = messages + + text_response_retries = 0 + consecutive_validation_failures = 0 + + for _iteration in range(max_iterations): + step = await sample_step_impl( + context, + messages=current_messages, + system_prompt=system_prompt, + temperature=temperature, + max_tokens=max_tokens, + model_preferences=model_preferences, + tools=sampling_tools, + tool_choice=tool_choice, + mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, + client_available=client_available, + ) + + # Check for final_response tool call for structured output + had_final_response = False + if result_type is not None and result_type is not str and step.is_tool_use: + for tool_call in step.tool_calls: + if tool_call.name == "final_response": + had_final_response = True + # Validate and return the structured result + type_adapter = get_cached_typeadapter(result_type) + + # Unwrap if we wrapped primitives (non-object schemas) + input_data = tool_call.input + original_schema = compress_schema( + type_adapter.json_schema(), prune_titles=True + ) + if ( + original_schema.get("type") != "object" + and isinstance(input_data, dict) + and "value" in input_data + ): + input_data = input_data["value"] + + try: + validated_result = type_adapter.validate_python(input_data) + text = json.dumps( + type_adapter.dump_python(validated_result, mode="json") + ) + return SamplingResult( + text=text, + result=validated_result, + history=step.history, + ) + except ValidationError as e: + consecutive_validation_failures += 1 + if consecutive_validation_failures > _MAX_VALIDATION_RETRIES: + raise RuntimeError( + f"Structured output validation failed " + f"{consecutive_validation_failures} consecutive " + f"times for type {result_type.__name__}: {e}" + ) from e + # Validation failed - add error as tool result + step.history.append( + SamplingMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id=tool_call.id, + content=[ + TextContent( + type="text", + text=( + f"Validation error: {e}. " + "Please try again with valid data." + ), + ) + ], + is_error=True, + ) + ], + ) + ) + + # The LLM called tools but not final_response — reset validation counter + if not had_final_response: + consecutive_validation_failures = 0 + + # If not a tool use response, we're done + if not step.is_tool_use: + # For structured output, the LLM must use the final_response tool + if result_type is not None and result_type is not str: + text_response_retries += 1 + if text_response_retries > _MAX_TEXT_RESPONSE_RETRIES: + raise RuntimeError( + f"Expected structured output of type {result_type.__name__}, " + "but the LLM returned a text response instead of calling " + f"the final_response tool ({text_response_retries} attempts)." + ) + # Nudge the LLM to use the tool + step.history.append( + SamplingMessage( + role="user", + content=TextContent( + type="text", + text=( + "You must call the `final_response` tool to provide " + "your answer. Do not respond with text — use the tool." + ), + ), + ) + ) + current_messages = step.history + continue + return SamplingResult( + text=step.text, + result=cast(ResultT, step.text if step.text else ""), + history=step.history, + ) + + # Continue with the updated history + current_messages = step.history + + # After first iteration, reset tool_choice to auto (unless structured output is required) + if result_type is None or result_type is str: + tool_choice = None + + raise RuntimeError(f"Sampling exceeded maximum iterations ({max_iterations})") diff --git a/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py b/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py new file mode 100644 index 000000000..05063dc53 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/sampling/sampling_tool.py @@ -0,0 +1,204 @@ +"""SamplingTool for use during LLM sampling requests.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any + +from mcp_types import TextContent +from mcp_types import Tool as SDKTool +from pydantic import ConfigDict + +from fastmcp.exceptions import AuthorizationError +from fastmcp.server.auth.authorization import AuthContext, run_auth_checks +from fastmcp.server.dependencies import get_access_token +from fastmcp.tools.base import ToolResult +from fastmcp.tools.function_parsing import ParsedFunction +from fastmcp.tools.function_tool import FunctionTool +from fastmcp.tools.tool_transform import TransformedTool +from fastmcp.utilities.types import FastMCPBaseModel + + +class SamplingTool(FastMCPBaseModel): + """A tool that can be used during LLM sampling. + + SamplingTools bundle a tool's schema (name, description, parameters) with + an executor function, enabling servers to execute agentic workflows where + the LLM can request tool calls during sampling. + + In most cases, pass functions directly to ctx.sample(): + + def search(query: str) -> str: + '''Search the web.''' + return web_search(query) + + result = await context.sample( + messages="Find info about Python", + tools=[search], # Plain functions work directly + ) + + Create a SamplingTool explicitly when you need custom name/description: + + tool = SamplingTool.from_function(search, name="web_search") + """ + + name: str + description: str | None = None + parameters: dict[str, Any] + fn: Callable[..., Any] + sequential: bool = False + + model_config = ConfigDict(arbitrary_types_allowed=True) + + async def run(self, arguments: dict[str, Any] | None = None) -> Any: + """Execute the tool with the given arguments. + + Args: + arguments: Dictionary of arguments to pass to the tool function. + + Returns: + The result of executing the tool function. + """ + if arguments is None: + arguments = {} + + result = self.fn(**arguments) + if inspect.isawaitable(result): + result = await result + return result + + def _to_sdk_tool(self) -> SDKTool: + """Convert to an mcp_types.Tool for SDK compatibility. + + This is used internally when passing tools to the MCP SDK's + create_message() method. + """ + return SDKTool( + name=self.name, + description=self.description, + input_schema=self.parameters, + ) + + @classmethod + def from_function( + cls, + fn: Callable[..., Any], + *, + name: str | None = None, + description: str | None = None, + sequential: bool = False, + ) -> SamplingTool: + """Create a SamplingTool from a function. + + The function's signature is analyzed to generate a JSON schema for + the tool's parameters. Type hints are used to determine parameter types. + + Args: + fn: The function to create a tool from. + name: Optional name override. Defaults to the function's name. + description: Optional description override. Defaults to the function's docstring. + sequential: If True, this tool requires sequential execution and prevents + parallel execution of all tools in the batch. Set to True for tools + with shared state, file writes, or other operations that cannot run + concurrently. Defaults to False. + + Returns: + A SamplingTool wrapping the function. + + Raises: + ValueError: If the function is a lambda without a name override. + """ + parsed = ParsedFunction.from_function(fn, validate=True) + + if name is None and parsed.name == "<lambda>": + raise ValueError("You must provide a name for lambda functions") + + return cls( + name=name or parsed.name, + description=description if description is not None else parsed.description, + parameters=parsed.input_schema, + fn=parsed.fn, + sequential=sequential, + ) + + @classmethod + def from_callable_tool( + cls, + tool: FunctionTool | TransformedTool, + *, + name: str | None = None, + description: str | None = None, + ) -> SamplingTool: + """Create a SamplingTool from a FunctionTool or TransformedTool. + + Reuses existing server tools in sampling contexts. For TransformedTool, + the tool's .run() method is used to ensure proper argument transformation, + and the ToolResult is automatically unwrapped. + + Args: + tool: A FunctionTool or TransformedTool to convert. + name: Optional name override. Defaults to tool.name. + description: Optional description override. Defaults to tool.description. + + Raises: + TypeError: If the tool is not a FunctionTool or TransformedTool. + """ + # Validate that the tool is a supported type + if not isinstance(tool, (FunctionTool, TransformedTool)): + raise TypeError( + f"Expected FunctionTool or TransformedTool, got {type(tool).__name__}. " + "Only callable tools can be converted to SamplingTools." + ) + + # Both FunctionTool and TransformedTool need .run() to ensure proper + # result processing (serializers, output_schema, wrap-result flags) + async def wrapper(**kwargs: Any) -> Any: + # Enforce per-tool auth checks, mirroring what the server + # dispatcher does for direct tool calls. Without this, an + # auth-protected tool wrapped as a SamplingTool could be + # invoked by the LLM during sampling without authorization. + if tool.auth is not None: + # Late import to avoid circular import with context.py + from fastmcp.server.context import _current_transport + + is_stdio = _current_transport.get() == "stdio" + if not is_stdio: + token = get_access_token() + ctx = AuthContext(token=token, component=tool) + if not await run_auth_checks(tool.auth, ctx): + raise AuthorizationError( + f"Authorization failed for tool '{tool.name}': " + "insufficient permissions" + ) + + result = await tool.run(kwargs) + # Unwrap ToolResult - extract the actual value + if isinstance(result, ToolResult): + # If there's structured_content, use that + if result.structured_content is not None: + # Check tool's schema - this is the source of truth + if tool.output_schema and tool.output_schema.get( + "x-fastmcp-wrap-result" + ): + # Tool wraps results: {"result": value} -> value + return result.structured_content.get("result") + else: + # No wrapping: use structured_content directly + return result.structured_content + # Otherwise, extract from text content + if result.content and len(result.content) > 0: + first_content = result.content[0] + if isinstance(first_content, TextContent): + return first_content.text + return result + + fn = wrapper + + # Extract the callable function, name, description, and parameters + return cls( + name=name or tool.name, + description=description or tool.description, + parameters=tool.parameters, + fn=fn, + ) diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index de3cba77b..13ad8e8d1 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -14,21 +14,23 @@ from contextlib import ( AbstractAsyncContextManager, asynccontextmanager, ) +from dataclasses import replace from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload import httpx2 import mcp_types +from key_value.aio.adapters.pydantic import PydanticAdapter +from key_value.aio.protocols import AsyncKeyValue +from key_value.aio.stores.memory import MemoryStore from mcp.server.lowlevel.server import LifespanResultT -from mcp.server.request_state import RequestStateSecurity from mcp.shared.exceptions import MCPError from mcp_types import ( Annotations, CallToolRequestParams, ToolAnnotations, ) -from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY from pydantic import AnyUrl from pydantic import ValidationError as PydanticValidationError from starlette.routing import BaseRoute @@ -65,18 +67,13 @@ from fastmcp.resources.security import ( from fastmcp.resources.template import ResourceTemplate from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks from fastmcp.server.caching import build_cache_hints -from fastmcp.server.completions import CompletionHandler from fastmcp.server.lifespan import Lifespan from fastmcp.server.low_level import LowLevelServer from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.server.middleware.middleware import ( - MiddlewarePhase, - _dispatch_phase, - mark_interior_dispatched, -) from fastmcp.server.mixins import LifespanMixin, MCPOperationsMixin, TransportMixin from fastmcp.server.providers import LocalProvider, Provider from fastmcp.server.providers.aggregate import AggregateProvider +from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.server.telemetry import server_span from fastmcp.server.transforms import ( ToolTransform, @@ -88,9 +85,8 @@ from fastmcp.tools.base import Tool, ToolResult from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.utilities.components import FastMCPComponent, _coerce_version -from fastmcp.utilities.exceptions import get_http_status_code, is_timeout_error +from fastmcp.utilities.exceptions import HTTP_STATUS_ERRORS, TIMEOUT_ERRORS from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT from fastmcp.utilities.versions import ( VersionSpec, @@ -98,13 +94,10 @@ from fastmcp.utilities.versions import ( ) if TYPE_CHECKING: - from key_value.aio.adapters.pydantic import PydanticAdapter - from key_value.aio.protocols import AsyncKeyValue - from fastmcp.client import Client from fastmcp.client.client import SDKServer + from fastmcp.client.sampling import SamplingHandler from fastmcp.client.transports import ClientTransport, ClientTransportT - from fastmcp.server.extensions import ServerExtension from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn from fastmcp.server.providers.openapi import RouteMap from fastmcp.server.providers.openapi import RouteMapFn as OpenAPIRouteMapFn @@ -112,6 +105,11 @@ if TYPE_CHECKING: logger = get_logger(__name__) +# Both-library catch tuples for user-supplied code that may still raise legacy +# httpx exceptions; see fastmcp.utilities.exceptions for the defensive import. +_ACTIONABLE_HTTP_STATUS_ERRORS = HTTP_STATUS_ERRORS +_ACTIONABLE_TIMEOUT_ERRORS = TIMEOUT_ERRORS + def _version_request_meta( version: VersionSpec | None, @@ -140,9 +138,8 @@ def _version_request_meta( # The MCP SDK warns "Tool X not listed, no validation will be performed" -# for every call addressed by hashed backend name, since that address is -# an identity rather than a listed tool name. This fires even when -# validate_input=False. Suppress it. +# for every call to app-only tools (hidden from list_tools by design). +# This fires even when validate_input=False. Suppress it. class _SuppressUnlistedToolWarning(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: return "not listed, no validation" not in record.getMessage() @@ -174,13 +171,11 @@ _REMOVED_KWARGS: dict[str, str] = { "include_tags": "Use `server.enable(tags=..., only=True)` after creating the server.", "exclude_tags": "Use `server.disable(tags=...)` after creating the server.", "tool_transformations": "Use `server.add_transform(ToolTransform(...))` after creating the server.", - "sampling_handler": "Server-initiated sampling was removed from MCP by SEP-2577. Call an LLM directly from your tool.", - "sampling_handler_behavior": "Server-initiated sampling was removed from MCP by SEP-2577. Call an LLM directly from your tool.", } def _check_removed_kwargs(kwargs: dict[str, Any]) -> None: - """Raise helpful TypeErrors for kwargs FastMCP no longer accepts.""" + """Raise helpful TypeErrors for kwargs removed in v3.""" for key in kwargs: if key in _REMOVED_KWARGS: raise TypeError( @@ -219,18 +214,64 @@ def _get_auth_context() -> tuple[bool, Any]: return (False, get_access_token()) -def _tool_identity(tool: Tool) -> str | None: - """Read a tool's stable identity hash, if it carries one.""" - from fastmcp.server.providers.addressing import TOOL_HASH_META_KEY +def _is_backend_tool(tool: Tool) -> bool: + """Check whether a tool is handled specially as backend tool + Tools registered via ``@app.tool()`` (without ``model=True``) have + ``meta["ui"]["visibility"] == ["app"]`` — they are callable by app UIs + but should not appear in tool list the client passes to the model. + + They are handled specially for in various ways - e.g. they are looked + up via get_app_tool(), and don't appear in the tools/list output. + (FIXME: the latter isn't correct behavior according to the mcp-apps spec.) + + Returns True (a backend tool) when: + - The tool has ``meta.fastmcp.app``. + - The tool has ``meta.ui.visibility``. + - The visibility is precisely ``["app"]``. + + Returns False otherwise. + """ meta = tool.meta if not meta: - return None - fastmcp_meta = meta.get("fastmcp") - if not isinstance(fastmcp_meta, dict): - return None - identity = fastmcp_meta.get(TOOL_HASH_META_KEY) - return identity if isinstance(identity, str) else None + return False + fastmcp = meta.get("fastmcp") + if not isinstance(fastmcp, dict): + return False + if fastmcp.get("app") is None: + return False + ui = meta.get("ui") + if not isinstance(ui, dict): + return False + visibility = ui.get("visibility") + if not isinstance(visibility, list): + return False + return len(visibility) == 1 and visibility[0] == "app" + + +def _is_app_visible(tool: Tool) -> bool: + """Check whether a tool has explicitly opted into app-callable visibility. + + Gates the dispatcher's hashed-name routing path: only tools whose + ``meta.ui.visibility`` list contains ``"app"`` can be reached via + ``<hash>_<local_name>`` calls. Tools without an explicit visibility + declaration are NOT app-callable — they must be reached by their + display name through the normal transform-aware resolution path. + + This is the inverse of the "everything is dot-callable" trap: the + hashed-name path is an opt-in mechanism for FastMCPApp backend tools, + not a general bypass for arbitrary tools. + """ + meta = tool.meta + if not meta: + return False + ui = meta.get("ui") + if not isinstance(ui, dict): + return False + visibility = ui.get("visibility") + if not isinstance(visibility, list): + return False + return "app" in visibility @asynccontextmanager @@ -302,11 +343,12 @@ class FastMCP( strict_input_validation: bool | None = None, list_page_size: int | None = None, resource_security: ResourceSecurity | None = DEFAULT_RESOURCE_SECURITY, - request_state_security: RequestStateSecurity | None = None, cache_ttl: int | None = None, cache_scope: Literal["public", "private"] | None = None, tasks: bool | None = None, session_state_store: AsyncKeyValue | None = None, + sampling_handler: SamplingHandler | None = None, + sampling_handler_behavior: Literal["always", "fallback"] | None = None, client_log_level: mcp_types.LoggingLevel | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, **kwargs: Any, @@ -328,8 +370,12 @@ class FastMCP( self._additional_http_routes: list[BaseRoute] = [] # Session-scoped state store (shared across all requests) - self._state_storage: AsyncKeyValue | None = session_state_store - self.__state_store: PydanticAdapter[StateValue] | None = None + self._state_storage: AsyncKeyValue = session_state_store or MemoryStore() + self._state_store: PydanticAdapter[StateValue] = PydanticAdapter[StateValue]( + key_value=self._state_storage, + pydantic_model=StateValue, + default_collection="fastmcp_state", + ) # Create LocalProvider for local components self._local_provider: LocalProvider = LocalProvider( @@ -364,36 +410,6 @@ class FastMCP( # server-wide. self._resource_security: ResourceSecurity | None = resource_security - # Server-level integrity policy for the multi-round-trip `requestState` - # (SEP-2322). Consumed by the low-level server, which installs the SDK's - # `RequestStateBoundary` middleware to seal every outgoing - # `InputRequiredResult.request_state` and unseal every inbound echo. - # None means "seal under a per-process ephemeral key" — correct for - # single-process deployments; multi-replica deployments must pass a - # policy carrying shared `keys=[...]`. - if ( - request_state_security is not None - and request_state_security.audience is None - and not name # None or "" both yield a random per-replica name - ): - # The request-state boundary stamps an audience claim, defaulting to - # the server name — which is auto-generated (random) when unnamed, so - # a shared-key multi-replica policy would mint tokens no other - # replica accepts. A policy object can't reveal whether its keys are - # shared (ephemeral and shared-key policies both collapse into a - # codec), so single-process customization stays allowed and the - # multi-replica footgun is a warning, not an error. - logger.warning( - "request_state_security was provided without an audience on an " - "unnamed server; if this policy's keys are shared across " - "replicas, sealed request state will not verify between them. " - "Pass FastMCP(name=...) or RequestStateSecurity(audience=...) " - "for a stable audience." - ) - self._request_state_security: RequestStateSecurity | None = ( - request_state_security - ) - # Server-level SEP-2549 cache hints, applied uniformly to every # SDK-cacheable result by the low-level server's runner (raises on # invalid ttl/scope). @@ -460,20 +476,8 @@ class FastMCP( experimental_capabilities or {} ) - # Server-level argument completion handler (set via @mcp.completion). - # The completions capability is declared only once this is set, because - # add_completion_handler registers the low-level completion/complete - # handler at that point (the SDK derives the capability from the handler). - self._completion_handler: CompletionHandler | None = None - self.middleware: list[Middleware] = list(middleware or []) - # Registered server extensions (SEP-2133), keyed by reverse-DNS - # identifier. Populated by add_extension; consumed by the low-level - # server (capability advertisement), the tool-call path (interception), - # and the lifespan manager (extension lifespans). - self._extensions: dict[str, ServerExtension] = {} - if dereference_schemas: from fastmcp.server.middleware.dereference import ( DereferenceRefsMiddleware, @@ -484,25 +488,14 @@ class FastMCP( # Set up MCP protocol handlers self._setup_handlers() + self.sampling_handler: SamplingHandler | None = sampling_handler + self.sampling_handler_behavior: Literal["always", "fallback"] = ( + sampling_handler_behavior or "fallback" + ) + def __repr__(self) -> str: return f"{type(self).__name__}({self.name!r})" - @property - def _state_store(self) -> PydanticAdapter[StateValue]: - """Create the session-state adapter only when state is first used.""" - if self.__state_store is None: - from key_value.aio.adapters.pydantic import PydanticAdapter - from key_value.aio.stores.memory import MemoryStore - - if self._state_storage is None: - self._state_storage = MemoryStore() - self.__state_store = PydanticAdapter[StateValue]( - key_value=self._state_storage, - pydantic_model=StateValue, - default_collection="fastmcp_state", - ) - return self.__state_store - @property def name(self) -> str: return self._mcp_server.name @@ -546,18 +539,8 @@ class FastMCP( self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any], - *, - phase: MiddlewarePhase = "all", ) -> Any: - """Builds and executes the middleware chain for a single dispatch phase. - - ``phase`` selects whether a pass runs only the method-agnostic hooks - (``"outer"``, at the root dispatch) or only the typed per-method hook - (``"typed"``, interior); it defaults to ``"all"`` for the direct - programmatic path. It is conveyed through the ``_dispatch_phase`` - ContextVar rather than the middleware call signature, so user middleware - overriding the documented ``__call__(context, call_next)`` is unaffected. - """ + """Builds and executes the middleware chain.""" chain = call_next for mw in reversed(self.middleware): next_chain: CallNext[Any, Any] = chain @@ -570,106 +553,11 @@ class FastMCP( return await mw(context, call_next) chain = cast(CallNext[Any, Any], wrapped) - token = _dispatch_phase.set(phase) - try: - return await chain(context) - finally: - _dispatch_phase.reset(token) - - async def _dispatch_component_middleware( - self, - context: MiddlewareContext[Any], - call_next: CallNext[Any, Any], - ) -> Any: - """Run the interior middleware chain for a component operation. - - This is the dispatch site for the component methods (``tools/call``, - ``tools/list``, ``resources/read``, ...). It runs the whole FastMCP chain - (``on_message`` -> ``on_request`` -> the typed per-method hook) in one - pass, so error-observing middleware see a tool exception propagate through - ``on_message``/``on_request`` exactly as they always have. It also records - (via ``mark_interior_dispatched``) that the chain fired for this wire - message, so the root dispatch knows not to observe it a second - time. - """ - mark_interior_dispatched() - return await self._run_middleware(context, call_next, phase="all") + return await chain(context) def add_middleware(self, middleware: Middleware) -> None: self.middleware.append(middleware) - def add_extension(self, extension: ServerExtension) -> None: - """Register a server extension (SEP-2133). - - An extension contributes a negotiated capability, additive request - methods, a `tools/call` interceptor, and an optional lifespan — each - with access to FastMCP-level constructs (the component registry, - `Context`, auth scope). Its capability is advertised only while it is - registered. - - The extension is bound to this server (so its handlers and interceptor - can reach it), its method bindings are wired onto the low-level server, - and it is recorded for capability advertisement, interception, and - lifespan entry. Registering two extensions with the same identifier is - an error, as is registering after the server's lifespan has started — - the extension's lifespan could no longer run, leaving it silently - half-active. - - Extensions are served by the server they are registered on. A mounted - child's extensions do not propagate to the root: the root serves the - wire, so only root-registered extensions advertise capabilities and - answer methods (matching the lifespan, which also defers to the root). - Register extensions on the server you run. - """ - from fastmcp.server.extensions import ( - build_method_handler, - validate_extension_identifier, - ) - - validate_extension_identifier( - extension.identifier, owner=type(extension).__name__ - ) - if extension.identifier in self._extensions: - raise ValueError( - f"An extension with identifier {extension.identifier!r} is " - "already registered." - ) - if self._lifespan_result_set: - raise RuntimeError( - f"Cannot register extension {extension.identifier!r}: the " - "server's lifespan has already started, so the extension's " - "lifespan would never run. Register extensions before serving." - ) - - extension._bind(self) - for binding in extension.methods(): - self._mcp_server.add_request_handler( - binding.method, - binding.params_type, - build_method_handler(binding), - ) - self._extensions[extension.identifier] = extension - - def _compose_tool_call_interceptors( - self, call_next: CallNext[Any, Any] - ) -> CallNext[Any, Any]: - """Nest every extension's `tools/call` interceptor around ``call_next``. - - Composes at the innermost point of the tool-call dispatch — after the - FastMCP middleware chain, before the tool body — so each interceptor is - the last gate before execution. First-registered extension is outermost. - A server with no extensions returns ``call_next`` unchanged, so there is - zero behaviour change. - """ - from fastmcp.server.extensions import wrap_tool_call_interceptor - - chain = call_next - for extension in reversed(list(self._extensions.values())): - chain = cast( - "CallNext[Any, Any]", wrap_tool_call_interceptor(extension, chain) - ) - return chain - def add_provider(self, provider: Provider, *, namespace: str = "") -> None: """Add a provider for dynamic tools, resources, and prompts. @@ -690,7 +578,7 @@ class FastMCP( """Replace placeholder Prefab URIs with per-tool hashed ones. For each tool whose ``meta.ui.resourceUri`` is the placeholder, - reads the tool's stored hash from ``meta.fastmcp.tool_hash`` + reads the tool's stored hash from ``meta.fastmcp._tool_hash`` and rewrites the URI to the per-tool form. Also strips CSP from tool meta (it belongs on the resource). Produces ``model_copy`` views — originals are untouched. @@ -704,78 +592,6 @@ class FastMCP( rewrite_tool_meta_for_wire(t) if _is_prefab_tool(t) else t for t in tools ] - async def _rebind_prefab_tool_names(self, result: Any) -> Any: - """Re-address a Prefab payload's tool references to this server's names. - - Runs on the way out of every ``tools/call``, above the middleware - chain so a payload is re-addressed however it was produced. Servers - unwind innermost-first, so the outermost server rewrites last and its - names — the only ones a client can actually invoke — are what ship. - - A call does not always answer with a tool result: submitting a task - answers with the task's metadata. Anything that is not a tool result - passes through untouched. - - An identity claimed by more than one tool is not bound. That happens - when one app is composed into a server twice, which leaves no fact - anywhere in the listing that says which copy a UI belongs to. The - reference keeps its identity-addressed form, and the dispatcher - reports the ambiguity rather than binding to a coin flip. - """ - from fastmcp.server.providers.prefab_payload import ( - payload_has_identities, - rewrite_payload_tool_names, - ) - - if not isinstance(result, ToolResult): - return result - - payload = result.structured_content - if not payload_has_identities(payload): - return result - - # Binding is safe only where one identity, one name, and one - # component all agree. Each is tracked separately: collapsing them - # early is what lets a duplicated app pass as a single tool. - # - # The middleware chain runs, because the binding has to describe the - # listing a client will actually see. Middleware adds, removes and - # shadows tools — an injected tool sharing a backend's name owns that - # name at call time, and a listing taken beneath middleware would not - # know it exists. - claimed_by: dict[str, list[Tool]] = {} - owners_of: dict[str, set[str | None]] = {} - for tool in await self.list_tools(): - identity = _tool_identity(tool) - owners_of.setdefault(tool.name, set()).add(identity) - if identity is not None: - claimed_by.setdefault(identity, []).append(tool) - - def resolve(identity: str) -> str | None: - tools = claimed_by.get(identity, []) - names = {tool.name for tool in tools} - if len(names) != 1: - # Several names carry this identity: the app is composed more - # than once and nothing says which copy the UI belongs to. - return None - - # One name can still be several components. `key` is the canonical - # identity — type, name and version — so versions of one tool have - # distinct keys while copies of one app repeat a key. A repeat - # means two components are indistinguishable, which is worse than - # the renamed case, not better. - if len({tool.key for tool in tools}) != len(tools): - return None - - (name,) = names - # And the name has to lead back. Two apps can each expose `save`, - # or a plain tool can share the name — binding then hands one - # app's button to someone else's implementation. - return name if owners_of.get(name) == {identity} else None - - rewrite_payload_tool_names(payload, resolve) - return result - # ------------------------------------------------------------------------- # Provider interface overrides - inherited from AggregateProvider # ------------------------------------------------------------------------- @@ -834,7 +650,7 @@ class FastMCP( async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]: """List all enabled tools from providers. - Overrides Provider.list_tools() to add enabled filtering, auth filtering, + Overrides Provider.list_tools() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ @@ -847,21 +663,18 @@ class FastMCP( method="tools/list", fastmcp_context=ctx, ) - return await self._dispatch_component_middleware( + return await self._run_middleware( context=mw_context, call_next=lambda context: self.list_tools(run_middleware=False), ) # Core logic: list tools with server_span("tools/list", "tools/list", self.name, "tool", ""): - # Get all tools, apply session transforms, then filter enabled. - # App-only tools (meta.ui.visibility == ["app"]) are listed: - # the mcp-apps spec puts visibility filtering on the host, and - # a tool absent from tools/list cannot be forwarded by any - # intermediary that routes by name. + # Get all tools, apply session transforms, then filter enabled + # and model-visible (app-only tools are hidden from the model). tools = list(await super().list_tools()) tools = await apply_session_transforms(tools) - tools = [t for t in tools if is_enabled(t)] + tools = [t for t in tools if is_enabled(t) and not _is_backend_tool(t)] # Rewrite per-tool Prefab renderer URIs based on the tool's # mount-point address. The walk pairs each tool with the @@ -919,7 +732,7 @@ class FastMCP( ) -> Tool | None: """Get a tool by name, filtering disabled tools. - Overrides Provider.get_tool() to filter disabled tools after all + Overrides Provider.get_tool() to add visibility filtering after all transforms (including session-level) have been applied. This ensures session transforms can override provider-level disables. @@ -939,18 +752,18 @@ class FastMCP( # Apply session transforms to single item tools = await apply_session_transforms([tool]) - if tools and is_enabled(tools[0]): + if tools and is_enabled(tools[0]) and not _is_backend_tool(tools[0]): return tools[0] - # The highest version is disabled. If an explicit version was - # requested, respect that. Otherwise fall back to the next-highest - # enabled version. + # The highest version is disabled (or app-only). If an explicit version + # was requested, respect that. Otherwise fall back to the next-highest + # enabled, model-visible version. if version is not None: return None all_tools = [t for t in await super().list_tools() if t.name == name] all_tools = list(await apply_session_transforms(all_tools)) - enabled = [t for t in all_tools if is_enabled(t)] + enabled = [t for t in all_tools if is_enabled(t) and not _is_backend_tool(t)] skip_auth, token = _get_auth_context() authorized: list[Tool] = [] @@ -986,7 +799,7 @@ class FastMCP( method="resources/list", fastmcp_context=ctx, ) - return await self._dispatch_component_middleware( + return await self._run_middleware( context=mw_context, call_next=lambda context: self.list_resources(run_middleware=False), ) @@ -1121,7 +934,7 @@ class FastMCP( method="resources/templates/list", fastmcp_context=ctx, ) - return await self._dispatch_component_middleware( + return await self._run_middleware( context=mw_context, call_next=lambda context: self.list_resource_templates( run_middleware=False @@ -1255,7 +1068,7 @@ class FastMCP( method="prompts/list", fastmcp_context=ctx, ) - return await self._dispatch_component_middleware( + return await self._run_middleware( context=mw_context, call_next=lambda context: self.list_prompts(run_middleware=False), ) @@ -1361,6 +1174,7 @@ class FastMCP( return None return max(authorized, key=version_sort_key) + @overload async def call_tool( self, name: str, @@ -1368,7 +1182,29 @@ class FastMCP( *, version: VersionSpec | None = None, run_middleware: bool = True, - ) -> ToolResult: + task_meta: None = None, + ) -> ToolResult: ... + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + version: VersionSpec | None = None, + run_middleware: bool = True, + task_meta: TaskMeta, + ) -> mcp_types.CreateTaskResult: ... + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + version: VersionSpec | None = None, + run_middleware: bool = True, + task_meta: TaskMeta | None = None, + ) -> ToolResult | mcp_types.CreateTaskResult: """Call a tool by name. This is the public API for executing tools. By default, middleware is applied. @@ -1379,14 +1215,13 @@ class FastMCP( version: Specific version to call. If None, calls highest version. run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. + task_meta: If provided, execute as a background task and return + CreateTaskResult. If None (default), execute synchronously and + return ToolResult. Returns: - ToolResult. - - A guard tool that requests client input (SEP-2322 multi-round-trip) - returns an ``InputRequiredToolResult`` (a ``ToolResult`` subclass); it - flows back through the middleware chain as an ordinary result and the - wire handler unwraps it into an ``InputRequiredResult`` on the response. + ToolResult when task_meta is None. + CreateTaskResult when task_meta is provided. Raises: NotFoundError: If tool not found or disabled @@ -1415,14 +1250,6 @@ class FastMCP( message=mcp_types.CallToolRequestParams( name=name, arguments=arguments or {}, - # Reflect the continuation fields (SEP-2322) so middleware - # reading `context.message` sees a continuation round as - # such, not as an initial call. These are recovered from - # the raw wire request (unsealed to plaintext by the - # request-state boundary); they drive middleware - # visibility only — `call_next` routes on name/arguments. - input_responses=ctx.input_responses, - request_state=ctx.request_state, _meta=_version_request_meta(version), ), source="client", @@ -1430,26 +1257,16 @@ class FastMCP( method="tools/call", fastmcp_context=ctx, ) - # Extension tools/call interceptors compose here, at the - # innermost point of dispatch: the FastMCP middleware chain wraps - # the whole thing (so it observes every call), and the - # interceptors sit between it and the tool body (so each is the - # last gate before execution). - dispatched = await self._dispatch_component_middleware( + return await self._run_middleware( context=mw_context, - call_next=self._compose_tool_call_interceptors( - lambda context: self.call_tool( - context.message.name, - context.message.arguments or {}, - version=version, - run_middleware=False, - ) + call_next=lambda context: self.call_tool( + context.message.name, + context.message.arguments or {}, + version=version, + run_middleware=False, + task_meta=task_meta, ), ) - # Above the chain, so a Prefab payload is re-addressed however - # it was produced — middleware can answer a call itself, and - # such a result never reaches the core path below. - return await self._rebind_prefab_tool_names(dispatched) # Core logic: find and execute tool with server_span( @@ -1488,8 +1305,10 @@ class FastMCP( if tool is None: raise NotFoundError(f"Unknown tool: {name!r}") span.set_attributes(tool.get_span_attributes()) + if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=tool.key) try: - return await tool._run(arguments or {}) + return await tool._run(arguments or {}, task_meta=task_meta) except ValidationError as e: # Argument-validation failure (a bad call). FunctionTool # converts pydantic's call-validation error into fastmcp's @@ -1520,32 +1339,18 @@ class FastMCP( ) raise except Exception as e: - # Most MCPErrors raised under a tool describe how the call - # went — a timeout, an upstream error a proxy forwarded — - # and are masked into an `isError` result like any other - # failure. A missing-client-capability error is different: - # it says the request cannot be serviced at all, and - # SEP-2575 requires it on the wire as -32021 (HTTP 400). - # Flattening it into a result would drop the code and tell - # the client the call had succeeded. - if ( - isinstance(e, MCPError) - and e.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY - ): - logger.debug( - "Tool %r requires a client capability the client did " - "not declare", - name, - ) - raise logger.exception(f"Error calling tool {name!r}") # Handle actionable errors that should reach the LLM # even when masking is enabled - if get_http_status_code(e) == 429: - raise ToolError( - "Rate limited by upstream API, please retry later" - ) from e - if is_timeout_error(e): + if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): + if ( + cast("httpx2.HTTPStatusError", e).response.status_code + == 429 + ): + raise ToolError( + "Rate limited by upstream API, please retry later" + ) from e + if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS): raise ToolError( "Upstream request timed out, please retry" ) from e @@ -1554,13 +1359,34 @@ class FastMCP( raise ToolError(f"Error calling tool {name!r}") from e raise ToolError(f"Error calling tool {name!r}: {e}") from e + @overload async def read_resource( self, uri: str, *, version: VersionSpec | None = None, run_middleware: bool = True, - ) -> ResourceResult: + task_meta: None = None, + ) -> ResourceResult: ... + + @overload + async def read_resource( + self, + uri: str, + *, + version: VersionSpec | None = None, + run_middleware: bool = True, + task_meta: TaskMeta, + ) -> mcp_types.CreateTaskResult: ... + + async def read_resource( + self, + uri: str, + *, + version: VersionSpec | None = None, + run_middleware: bool = True, + task_meta: TaskMeta | None = None, + ) -> ResourceResult | mcp_types.CreateTaskResult: """Read a resource by URI. This is the public API for reading resources. By default, middleware is applied. @@ -1571,14 +1397,25 @@ class FastMCP( version: Specific version to read. If None, reads highest version. run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. + task_meta: If provided, execute as a background task and return + CreateTaskResult. If None (default), execute synchronously and + return ResourceResult. Returns: - ResourceResult. + ResourceResult when task_meta is None. + CreateTaskResult when task_meta is provided. Raises: NotFoundError: If resource not found or disabled ResourceError: If resource read fails """ + # Note: fn_key enrichment happens here after finding the resource/template. + # Resources and templates use different key formats: + # - Resources use resource.key (derived from the concrete URI) + # - Templates use template.key (the template pattern) + # For mounted servers, the parent's provider sets fn_key to the + # namespaced key before delegating, ensuring correct Docket routing. + async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( @@ -1591,12 +1428,13 @@ class FastMCP( method="resources/read", fastmcp_context=ctx, ) - return await self._dispatch_component_middleware( + return await self._run_middleware( context=mw_context, call_next=lambda context: self.read_resource( str(context.message.uri), version=version, run_middleware=False, + task_meta=task_meta, ), ) @@ -1619,14 +1457,16 @@ class FastMCP( synthesized = await synthesize_prefab_resource_by_uri(self, uri) if synthesized is not None: span.set_attributes(synthesized.get_span_attributes()) - return await synthesized._read() + return await synthesized._read(task_meta=task_meta) # Try concrete resources first (transforms + auth via _get_resource) resource = await self.get_resource(uri, version=version) if resource is not None: span.set_attributes(resource.get_span_attributes()) + if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=resource.key) try: - return await resource._read() + return await resource._read(task_meta=task_meta) except FastMCPError as e: logger.log( e.log_level, @@ -1640,11 +1480,15 @@ class FastMCP( except Exception as e: logger.exception(f"Error reading resource {uri!r}") # Handle actionable errors that should reach the LLM - if get_http_status_code(e) == 429: - raise ResourceError( - "Rate limited by upstream API, please retry later" - ) from e - if is_timeout_error(e): + if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): + if ( + cast("httpx2.HTTPStatusError", e).response.status_code + == 429 + ): + raise ResourceError( + "Rate limited by upstream API, please retry later" + ) from e + if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS): raise ResourceError( "Upstream request timed out, please retry" ) from e @@ -1686,8 +1530,10 @@ class FastMCP( ) raise ResourceSecurityError(f"Unknown resource: {uri!r}") + if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=template.key) try: - return await template._read(uri, params) + return await template._read(uri, params, task_meta=task_meta) except FastMCPError as e: logger.log( e.log_level, f"Error reading resource {uri!r}", exc_info=True @@ -1699,11 +1545,15 @@ class FastMCP( except Exception as e: logger.exception(f"Error reading resource {uri!r}") # Handle actionable errors that should reach the LLM - if get_http_status_code(e) == 429: - raise ResourceError( - "Rate limited by upstream API, please retry later" - ) from e - if is_timeout_error(e): + if isinstance(e, _ACTIONABLE_HTTP_STATUS_ERRORS): + if ( + cast("httpx2.HTTPStatusError", e).response.status_code + == 429 + ): + raise ResourceError( + "Rate limited by upstream API, please retry later" + ) from e + if isinstance(e, _ACTIONABLE_TIMEOUT_ERRORS): raise ResourceError( "Upstream request timed out, please retry" ) from e @@ -1712,6 +1562,7 @@ class FastMCP( raise ResourceError(f"Error reading resource {uri!r}") from e raise ResourceError(f"Error reading resource {uri!r}: {e}") from e + @overload async def render_prompt( self, name: str, @@ -1719,7 +1570,29 @@ class FastMCP( *, version: VersionSpec | None = None, run_middleware: bool = True, - ) -> PromptResult: + task_meta: None = None, + ) -> PromptResult: ... + + @overload + async def render_prompt( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + version: VersionSpec | None = None, + run_middleware: bool = True, + task_meta: TaskMeta, + ) -> mcp_types.CreateTaskResult: ... + + async def render_prompt( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + version: VersionSpec | None = None, + run_middleware: bool = True, + task_meta: TaskMeta | None = None, + ) -> PromptResult | mcp_types.CreateTaskResult: """Render a prompt by name. This is the public API for rendering prompts. By default, middleware is applied. @@ -1731,9 +1604,13 @@ class FastMCP( version: Specific version to render. If None, renders highest version. run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. + task_meta: If provided, execute as a background task and return + CreateTaskResult. If None (default), execute synchronously and + return PromptResult. Returns: - PromptResult. + PromptResult when task_meta is None. + CreateTaskResult when task_meta is provided. Raises: NotFoundError: If prompt not found or disabled @@ -1752,13 +1629,14 @@ class FastMCP( method="prompts/get", fastmcp_context=ctx, ) - return await self._dispatch_component_middleware( + return await self._run_middleware( context=mw_context, call_next=lambda context: self.render_prompt( context.message.name, context.message.arguments, version=version, run_middleware=False, + task_meta=task_meta, ), ) @@ -1776,8 +1654,10 @@ class FastMCP( if prompt is None: raise NotFoundError(f"Unknown prompt: {name!r}") span.set_attributes(prompt.get_span_attributes()) + if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=prompt.key) try: - return await prompt._render(arguments) + return await prompt._render(arguments, task_meta=task_meta) except FastMCPError as e: logger.log( e.log_level, f"Error rendering prompt {name!r}", exc_info=True @@ -1983,6 +1863,7 @@ class FastMCP( annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, app: AppConfig | dict[str, Any] | bool | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, security: ResourceSecurity | None | InheritSecurity = INHERIT_SECURITY, ) -> Callable[[F], F]: @@ -2082,6 +1963,7 @@ class FastMCP( tags=tags, annotations=annotations, meta=meta, + task=task if task is not None else self._support_tasks_by_default, auth=auth, security=security, ) @@ -2111,6 +1993,7 @@ class FastMCP( icons: list[mcp_types.Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> F: ... @@ -2126,6 +2009,7 @@ class FastMCP( icons: list[mcp_types.Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @@ -2140,6 +2024,7 @@ class FastMCP( icons: list[mcp_types.Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, + task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionPrompt] @@ -2224,87 +2109,10 @@ class FastMCP( icons=icons, tags=tags, meta=meta, + task=task if task is not None else self._support_tasks_by_default, auth=auth, ) - def add_completion_handler(self, handler: CompletionHandler) -> None: - """Register the server's argument-completion handler. - - A server has a single completion handler that answers every - `completion/complete` request, switching on the reference (a prompt or - resource template) and the argument being completed. Registering it also - registers the low-level `completion/complete` handler, which is what - makes the SDK declare the completions capability — so the capability is - advertised exactly when the server can answer. Calling this again - replaces the handler. - - Args: - handler: A callable taking the reference, the - `CompletionArgument`, and the optional `CompletionContext`, and - returning candidate values (a `Completion`, a list of strings, - or None). May be sync or async. - """ - self._completion_handler = handler - self._register_completion_handler() - - @overload - def completion(self, handler: CompletionHandler) -> CompletionHandler: ... - - @overload - def completion( - self, - ) -> Callable[[CompletionHandler], CompletionHandler]: ... - - def completion( - self, - handler: CompletionHandler | None = None, - ) -> CompletionHandler | Callable[[CompletionHandler], CompletionHandler]: - """Decorator to register the server's argument-completion handler. - - The handler answers `completion/complete` requests for prompt arguments - and resource-template parameters. It receives the reference being - completed, the argument (its name and the partial value typed so far), - and the context of arguments already supplied, and returns candidate - values. Return a list of strings, a `Completion` (to include pagination - hints), or None when the reference/argument is not one it handles — an - unhandled reference yields an empty completion, not an error. - - Registering a handler declares the completions capability; a server with - none does not advertise it. This works identically on the handshake and - modern protocol eras. - - Supports both `@mcp.completion` and `@mcp.completion()`. - - Example: - - ```python - from fastmcp import FastMCP - from mcp_types import Completion, PromptReference - - mcp = FastMCP("Completion Server") - - @mcp.prompt - def poem(theme: str) -> str: - return f"Write a poem about {theme}" - - @mcp.completion - def complete(ref, argument, context): - if isinstance(ref, PromptReference) and ref.name == "poem": - if argument.name == "theme": - options = ["nature", "love", "adventure"] - return [o for o in options if o.startswith(argument.value)] - return None - ``` - """ - - def register(fn: CompletionHandler) -> CompletionHandler: - self.add_completion_handler(fn) - return fn - - if handler is None: - return register - return register(handler) - def mount( self, server: FastMCP[LifespanResultT], @@ -2395,10 +2203,10 @@ class FastMCP( Args: openapi_spec: OpenAPI schema as a dictionary client: Optional httpx2 AsyncClient for making HTTP requests. - If not provided, a default client is created using the first + An httpx (v1) AsyncClient is also accepted and works via + duck-typing. If not provided, a default client is created + using the first server URL from the OpenAPI spec with a 30-second timeout. - Legacy httpx clients are temporarily accepted with a deprecation - warning. name: Name for the MCP server route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping @@ -2510,8 +2318,6 @@ def create_proxy( | dict[str, Any] | str ), - *, - mode: str | None = None, **settings: Any, ) -> FastMCPProxy: """Create a FastMCP proxy server for the given target. @@ -2527,17 +2333,6 @@ def create_proxy( - A URL string or AnyUrl - A Path to a server script - An MCPConfig or dict - mode: Protocol-era negotiation for auto-created proxy clients (a - non-Client target). By default (``None``) the backend MIRRORS the - front connection's negotiated era per request, so the whole chain - speaks one era end-to-end: a modern front reaches a modern backend - (a guard tool's `InputRequiredResult` (SEP-2322) round-trips) and a - handshake front reaches a handshake backend (server-initiated - sampling / elicitation / roots push-forwarding works). Pass an - explicit mode (e.g. ``"auto"`` or a version string) to pin the - backend era regardless of the front; this overrides mirroring and is - appropriate when the backend only speaks one era. Ignored when - `target` is already a `Client` (which carries its own mode). **settings: Additional settings passed to FastMCPProxy (name, etc.) Returns: @@ -2559,7 +2354,7 @@ def create_proxy( _create_client_factory, ) - client_factory = _create_client_factory(target, mode=mode) + client_factory = _create_client_factory(target) return FastMCPProxy( client_factory=client_factory, **settings, diff --git a/fastmcp_slim/fastmcp/server/session_scoped_event_store.py b/fastmcp_slim/fastmcp/server/session_scoped_event_store.py deleted file mode 100644 index 9d0adada4..000000000 --- a/fastmcp_slim/fastmcp/server/session_scoped_event_store.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Lightweight session scoping for Streamable HTTP event stores.""" - -from __future__ import annotations - -from mcp.server.streamable_http import ( - EventCallback, - EventId, - EventMessage, - EventStore, - StreamId, -) -from mcp_types import JSONRPCMessage - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class SessionScopedEventStore(EventStore): - """EventStore adapter that isolates stream IDs to one transport session.""" - - def __init__(self, event_store: EventStore, session_id: str): - self._event_store = event_store - self._stream_prefix = f"{len(session_id)}:{session_id}:" - - def _scope_stream_id(self, stream_id: StreamId) -> StreamId: - return f"{self._stream_prefix}{stream_id}" - - def _unscope_stream_id(self, stream_id: StreamId) -> StreamId | None: - if not stream_id.startswith(self._stream_prefix): - return None - return stream_id[len(self._stream_prefix) :] - - async def store_event( - self, stream_id: StreamId, message: JSONRPCMessage | None - ) -> EventId: - return await self._event_store.store_event( - self._scope_stream_id(stream_id), message - ) - - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - replayed_events: list[EventMessage] = [] - - async def buffer_event(event: EventMessage) -> None: - replayed_events.append(event) - - scoped_stream_id = await self._event_store.replay_events_after( - last_event_id, buffer_event - ) - if scoped_stream_id is None: - return None - - stream_id = self._unscope_stream_id(scoped_stream_id) - if stream_id is None: - logger.warning( - "Event ID %s does not belong to this session-scoped event store", - last_event_id, - ) - return None - - for event in replayed_events: - await send_callback(event) - - return stream_id diff --git a/fastmcp_slim/fastmcp/server/sessions.py b/fastmcp_slim/fastmcp/server/sessions.py deleted file mode 100644 index a68a462f8..000000000 --- a/fastmcp_slim/fastmcp/server/sessions.py +++ /dev/null @@ -1,537 +0,0 @@ -"""Stateless session state: server-side per-user and per-session storage. - -Modern (2026-07-28) MCP connections are stateless by construction — every -request builds a fresh connection whose in-memory state is discarded when the -request returns. This module gives tools two explicit ways to keep state across -calls, both backed by the server's existing state store and both isolated by the -authenticated principal rather than by any client-declared identifier. - -- `Session`: async `get`/`set`/`delete`/`clear` over a single dict stored under - one key, scoped to a `(principal, session_id)` pair. This is the state-accessor - object a handler works with — the value the standalone `get_session(id)` - returns and the value injected for a `UserSession` parameter. -- `session: UserSession` (injected): a per-user bucket, dependency-injected like - `ctx: Context` and keyed by the request's authenticated principal. Requires - auth. `UserSession` is the injection annotation; the injected value is a - `Session`. It is always available under auth — no `create_session`, no - provider, no validation. -- `session_id: SessionId` (argument): a required string the agent supplies, - resolved with the standalone `await get_session(session_id)`. The id is - minted - by `create_session`; an id that was never created (or was created under a - different principal) is rejected. This validation is the whole guarantee — an - unminted id never resolves, so nothing enforces provider registration. -- `SessionProvider`: a `Provider` contributing `create_session` / `end_session` - tools. Register it with `mcp.add_provider(SessionProvider())` so a tool that - takes `session_id` has a way to mint ids; without it, no id can be created, so - those tools simply cannot resolve a session. - -Isolation is the authenticated principal, not the session id. State keyed by -`(principal, session_id)` means a request under principal B can never address -principal A's keys, no matter what `session_id` it passes; the id only organizes -sessions within a principal. Without auth there is no principal wall — a session -id is a bearer capability and sessions are not a boundary between clients. -""" - -from __future__ import annotations - -import functools -import hashlib -import inspect -import json -import time -from collections.abc import Callable, Sequence -from functools import lru_cache -from types import TracebackType -from typing import ( - TYPE_CHECKING, - Annotated, - Any, - Final, - cast, - get_args, - get_origin, - get_type_hints, -) -from uuid import uuid4 - -from mcp.server.auth.provider import principal_components -from uncalled_for import Dependency - -from fastmcp.exceptions import FastMCPError -from fastmcp.server.dependencies import get_access_token, get_server, get_session -from fastmcp.server.providers.base import Provider -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from key_value.aio.adapters.pydantic import PydanticAdapter - - from fastmcp.server.server import StateValue - from fastmcp.tools.base import Tool - -logger = get_logger(__name__) - - -# The description the framework auto-populates onto a `SessionId` argument so an -# agent reading the tool schema learns the create-then-pass contract with no -# hand-prompting. -# Deliberately names no specific tool. The session-creation tool can be renamed -# by composition — mounting a server under a namespace exposes it as, e.g., -# `child_create_session` — so hard-coding a tool name here would point agents at -# a tool that does not exist under that mount. Describing the capability keeps -# the contract correct regardless of how the lifecycle tool is named. -SESSION_ID_DESCRIPTION: Final[str] = ( - "Session identifier. Use a tool to create a session, then pass the resulting " - "id here to persist state across calls in the same session." -) - -# Reserved top-level keys in a session's stored dict. User state lives under -# `_STATE_KEY` (a sub-dict), and `_MARKER_KEY` records that the session was -# created. Keeping user state in a sub-dict means normal `set`/`delete`/`clear` -# can never collide with or clobber the creation marker, so a created session -# stays distinguishable from a missing one — including after `clear()`, which -# empties the sub-dict but leaves the marker in place. -_MARKER_KEY: Final[str] = "_created" -_STATE_KEY: Final[str] = "state" - -# Fixed session-id suffix for the injected per-user bucket. The principal is -# already hashed into the key's namespace segment (`_principal_segment`), which -# alone makes the bucket unique per user — using the *raw* principal again as -# the id suffix would embed unhashed identity data (issuer, client id, subject) -# in the storage key and in any logs that record it. A reserved constant avoids -# that while a `create_session`-minted uuid4 can never collide with it. -_USER_SESSION_ID: Final[str] = "_user" - - -class SessionAuthError(FastMCPError): - """An injected `session: UserSession` was requested with no authenticated principal. - - Per-user session injection keys off the request's authenticated principal, so - it is only meaningful under auth. A tool that needs cross-call state without - auth should take a `session_id: SessionId` argument instead. - """ - - def __init__( - self, - message: str = ( - "Injected `session: UserSession` requires an authenticated principal, " - "but this request is unauthenticated. Use a `session_id: SessionId` " - "argument for cross-call state on unauthenticated connections." - ), - ) -> None: - super().__init__(message) - - -class InvalidSession(FastMCPError): - """A session id did not resolve to a session created under the current principal. - - Raised by `get_session(session_id)` when the id was never created, or was - created under a different principal. The public message is deliberately - generic — the specific reason (which id, which principal) is logged at debug - level, not returned to the caller, so an attacker cannot distinguish "unknown - id" from "belongs to someone else". - """ - - def __init__(self, message: str = "Invalid or unknown session.") -> None: - super().__init__(message) - - -def current_principal() -> str | None: - """The authenticated principal for the current request as a compact JSON string. - - Returns the `(client_id, issuer, subject)` triple encoded as compact JSON, or - `None` on an unauthenticated request. Two users of one OAuth client are - distinct principals whenever the token verifier supplies a subject. - """ - token = get_access_token() - if token is None: - return None - return json.dumps(principal_components(token), separators=(",", ":")) - - -def _principal_segment(principal: str | None) -> str: - """A fixed-length, delimiter-safe key segment for a principal. - - Hashing keeps an arbitrary principal string from injecting the `:` key - delimiter and bounds the key length. `None` (unauthenticated) collapses to a - single shared `anon` segment — without a principal there is no isolation wall. - """ - if principal is None: - return "anon" - return hashlib.sha256(principal.encode("utf-8", "surrogatepass")).hexdigest() - - -def session_storage_key(principal: str | None, session_id: str) -> str: - """The single storage key holding a session's state dict. - - Keyed by `(principal, session_id)`: the principal is the isolation wall, the - id organizes sessions within it. A session's whole state lives under this one - key as a dict, so one key means one store TTL per session and `end` is a - single delete. - """ - return f"session:{_principal_segment(principal)}:{session_id}" - - -class Session: - """Async accessors over one `(principal, session_id)` bucket of state. - - A session's state is a single dict stored under one key. That dict holds user - state in a `state` sub-dict and a small creation marker alongside it, so a - created-but-empty session is still distinguishable from a missing one. - `get`/`set`/`delete` read-modify-write the sub-dict; `clear` empties the - sub-dict but keeps the session valid; `end` deletes the whole key. Writes - never impose a TTL — retention is entirely the server store's (configure it on - the store you pass to `FastMCP(session_state_store=...)`). - - Concurrent writes to one session race on the read-modify-write; session state - is small and typically driven serially by one agent, so this is acceptable. - """ - - def __init__( - self, - *, - store: PydanticAdapter[StateValue], - principal: str | None, - session_id: str, - public_id: str | None = None, - ) -> None: - self._store = store - self._principal = principal - self._session_id = session_id - self._public_id = public_id - self._key = session_storage_key(principal, session_id) - - @property - def id(self) -> str | None: - """The session's identifier, or `None` for an injected per-user session. - - For a session resolved from a `session_id` argument (or minted by - `create_session`) this is that id. An injected `UserSession` has no - distinct id — its bucket is the authenticated user — so it is `None`; the - internal principal-derived key is deliberately not exposed here. - """ - return self._public_id - - async def _load_raw(self) -> dict[str, Any] | None: - """Read the session's full stored dict, or `None` when the key is unset.""" - result = await self._store.get(key=self._key) - if result is None: - return None - value = result.value - return dict(value) if isinstance(value, dict) else None - - async def _save_raw(self, data: dict[str, Any]) -> None: - """Write the session's full dict back under its single key (no TTL).""" - from fastmcp.server.server import StateValue - - await self._store.put(key=self._key, value=StateValue(value=data)) - - @staticmethod - def _state_of(raw: dict[str, Any] | None) -> dict[str, Any]: - """The user-state sub-dict of a raw stored dict (empty when absent).""" - if raw is None: - return {} - state = raw.get(_STATE_KEY) - return dict(state) if isinstance(state, dict) else {} - - async def _exists(self) -> bool: - """Whether a session record exists for this `(principal, session_id)`. - - True only once `create_session` has written the creation marker. A raw - store entry without the marker (e.g. an injected `UserSession` bucket) is - not a created session and does not satisfy this check. - """ - raw = await self._load_raw() - return raw is not None and _MARKER_KEY in raw - - async def _create(self) -> None: - """Write the initial record so the session exists (called by `create_session`).""" - raw = await self._load_raw() or {} - raw[_MARKER_KEY] = time.time() - raw.setdefault(_STATE_KEY, {}) - await self._save_raw(raw) - - async def get(self, key: str, default: Any = None) -> Any: - """Return the value for `key`, or `default` when it is not set.""" - raw = await self._load_raw() - return self._state_of(raw).get(key, default) - - async def set(self, key: str, value: Any) -> None: - """Store `value` under `key` in this session (read-modify-write). - - Preserves the creation marker: only the user-state sub-dict is touched. - """ - raw = await self._load_raw() or {} - state = self._state_of(raw) - state[key] = value - raw[_STATE_KEY] = state - await self._save_raw(raw) - - async def delete(self, key: str) -> None: - """Remove `key` from this session, if present (preserves the marker).""" - raw = await self._load_raw() - if raw is None: - return - state = self._state_of(raw) - if key in state: - del state[key] - raw[_STATE_KEY] = state - await self._save_raw(raw) - - async def clear(self) -> None: - """Empty the session's user state but keep the session valid. - - The user-state sub-dict is reset to empty while the creation marker stays - in place, so a cleared session still resolves through `get_session`. - To invalidate a session entirely, use `end` (what `end_session` calls). - """ - raw = await self._load_raw() - if raw is None: - return - raw[_STATE_KEY] = {} - await self._save_raw(raw) - - async def end(self) -> None: - """Invalidate the session — delete its one key and all of its state. - - After this the id no longer resolves through `get_session`. This is - what `end_session` calls; `clear` only empties state and keeps the session. - """ - await self._store.delete(key=self._key) - - -class UserSession(Session): - """Annotation marker for the injected per-user session. - - A `session: UserSession` parameter is **dependency-injected** like - `ctx: Context`: keyed by the request's authenticated principal, excluded from - the input schema, and requiring auth (it raises `SessionAuthError` with no - principal). It doubles as the injection *annotation* and the injected - type — the value a handler receives is a `UserSession`, which subclasses - `Session`, so `await session.get(...)`, `.set`, `.delete`, and `.clear` all - work exactly as on any other `Session`. - - Unlike `session_id: SessionId`, the per-user bucket needs no `create_session`, - no `SessionProvider`, and no validation — it is always available under auth, - keyed directly by the caller's identity. - - ```python - from fastmcp.server.sessions import UserSession - - @mcp.tool - async def remember(fact: str, session: UserSession) -> str: - await session.set("fact", fact) - return "noted" - ``` - - Subclasses `Session` only so the framework's type-based injection detector can - key off it; it adds no behavior of its own. - """ - - -class _SessionIdMarker: - """Metadata marker identifying a `SessionId`-annotated parameter.""" - - -# A `session_id: SessionId` parameter is a plain required string in the input -# schema (the agent supplies it); the marker lets the framework recognize it and -# auto-populate its description with the create-then-pass contract. -SessionId = Annotated[str, _SessionIdMarker()] - - -@lru_cache(maxsize=5000) -def session_id_parameter_names(fn: Callable[..., object]) -> tuple[str, ...]: - """Names of a function's parameters annotated with `SessionId`. - - Scans resolved type hints for `Annotated[str, _SessionIdMarker()]` metadata. - Returns an empty tuple when the hints cannot be resolved (the function then - simply carries no auto-populated session-id description). - - `functools.partial` is unwrapped first, since `get_type_hints` rejects a - partial object — FastMCP supports registering a partial as a tool, and its - schema is still built from the underlying function, so its `SessionId` - parameters must be detected here too. Parameters the partial has already - bound — positionally or by keyword — are dropped, matching the tool's actual - argument surface (the partial's own signature already reflects this). - """ - target: object = fn - while isinstance(target, functools.partial): - target = target.func - if not callable(target): - return () - try: - hints = get_type_hints(target, include_extras=True) - except (TypeError, NameError): - return () - # `inspect.signature` on the (possibly partial) callable reports only the - # parameters still open to callers — a partial's bound positional and keyword - # arguments are already removed — so it is the source of truth for the tool's - # argument surface. Fall back to accepting every hinted name if the signature - # cannot be read. - try: - remaining = set(inspect.signature(fn).parameters) - except (TypeError, ValueError): - remaining = None - names: list[str] = [] - for name, hint in hints.items(): - if name == "return" or (remaining is not None and name not in remaining): - continue - if get_origin(hint) is not Annotated: - continue - if any(isinstance(meta, _SessionIdMarker) for meta in get_args(hint)[1:]): - names.append(name) - return tuple(names) - - -def _current_user_session() -> UserSession | None: - """Build the per-user session for the current principal, or `None` if unauth. - - Resolves the store through `get_server()` rather than `get_context()`: a - `task=True` tool whose only injected dependency is `UserSession` runs in a - Docket worker with no foreground context, and `get_server()` is task-aware (it - resolves via the task-server map in a worker). - """ - principal = current_principal() - if principal is None: - return None - return UserSession( - store=get_server()._state_store, - principal=principal, - session_id=_USER_SESSION_ID, - ) - - -class _CurrentSession(Dependency["Session"]): - """Dependency that injects a per-user `Session` keyed by the request principal. - - Mirrors `_CurrentContext`: a `session: UserSession` parameter is rewritten to - default to this dependency, so it is excluded from the input schema and - resolved at call time. Raises `SessionAuthError` when the request carries no - authenticated principal. - """ - - async def __aenter__(self) -> Session: - session = _current_user_session() - if session is None: - raise SessionAuthError - return session - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - return None - - -class _OptionalCurrentSession(Dependency["Session | None"]): - """Dependency for an *optional* per-user session (`session: UserSession | None`). - - Mirrors `_OptionalCurrentContext`: when the request carries no authenticated - principal it injects `None` instead of raising, so a handler that declares the - parameter optional (default `None`) can run on unauthenticated requests and - branch on whether a session is available. - """ - - async def __aenter__(self) -> Session | None: - return _current_user_session() - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - return None - - -def CurrentSession() -> Session: - """Inject the per-user `Session` for the current authenticated principal. - - Rarely written explicitly — a `session: UserSession` parameter is rewritten - to this. Provided for parity with `CurrentContext()` when an explicit default - is preferred. - """ - return cast("Session", _CurrentSession()) - - -def OptionalCurrentSession() -> Session | None: - """Inject the per-user `Session`, or `None` when the request is unauthenticated. - - Rarely written explicitly — a `session: UserSession | None = None` parameter - is rewritten to this. Provided for parity with `OptionalCurrentContext()`. - """ - return cast("Session | None", _OptionalCurrentSession()) - - -async def create_session() -> str: - """Create a new session and return its identifier. - - Mints an unguessable `uuid4`, records an initial session owned by the current - principal, and returns the id as a string. Store it and pass it back as a - `session_id` argument on later calls to persist state across a session — only - an id created this way resolves. State is keyed by the authenticated - principal, so the id organizes sessions within a user; on an unauthenticated - connection the id is the only thing standing between callers, which is why it - is unguessable. - """ - session_id = str(uuid4()) - session = Session( - store=get_server()._state_store, - principal=current_principal(), - session_id=session_id, - public_id=session_id, - ) - await session._create() - return session_id - - -async def end_session(session_id: SessionId) -> str: - """End a session and delete all of its state. - - Validates the id like any other resolution (an unknown or foreign id is - rejected), then deletes the session's key so the id no longer resolves. - """ - session = await get_session(session_id) - await session.end() - return "session ended" - - -class SessionProvider(Provider): - """Provider contributing the session lifecycle tools. - - Register it whenever a tool declares a `session_id: SessionId` argument: - - ```python - from fastmcp.server.sessions import SessionProvider - - mcp.add_provider(SessionProvider()) - ``` - - It registers two tools: - - - `create_session()` mints an unguessable `uuid4`, records the session, and - returns the id. - - `end_session(session_id)` invalidates that session and deletes its state. - - It owns no storage (session state lives in the server's configured - `session_state_store`) and imposes no TTL (retention is the store's). It - exists to mint and end owned session ids. Registration is not enforced: with - no provider, no id can be created, so every `get_session(...)` rejects — - a `session_id` tool without a provider simply cannot resolve a session. - """ - - def __init__(self) -> None: - super().__init__() - self._tools: list[Tool] | None = None - - async def _list_tools(self) -> Sequence[Tool]: - if self._tools is None: - from fastmcp.tools.base import Tool - - self._tools = [ - Tool.from_function(create_session), - Tool.from_function(end_session), - ] - return self._tools diff --git a/fastmcp_slim/fastmcp/server/tasks/__init__.py b/fastmcp_slim/fastmcp/server/tasks/__init__.py new file mode 100644 index 000000000..008332db5 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/__init__.py @@ -0,0 +1,38 @@ +"""MCP SEP-1686 background tasks support. + +This module implements protocol-level background task execution for MCP servers. +""" + +from fastmcp.server.tasks.capabilities import get_task_capabilities +from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode +from fastmcp.server.tasks.elicitation import ( + elicit_for_task, + handle_task_input, + relay_elicitation, +) +from fastmcp.server.tasks.keys import ( + build_task_key, + get_client_task_id_from_key, + parse_task_key, +) +from fastmcp.server.tasks.notifications import ( + ensure_subscriber_running, + push_notification, + stop_subscriber, +) + +__all__ = [ + "TaskConfig", + "TaskMeta", + "TaskMode", + "build_task_key", + "elicit_for_task", + "ensure_subscriber_running", + "get_client_task_id_from_key", + "get_task_capabilities", + "handle_task_input", + "parse_task_key", + "push_notification", + "relay_elicitation", + "stop_subscriber", +] diff --git a/fastmcp_slim/fastmcp/server/tasks/capabilities.py b/fastmcp_slim/fastmcp/server/tasks/capabilities.py new file mode 100644 index 000000000..d2ed14ff4 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/capabilities.py @@ -0,0 +1,48 @@ +"""SEP-1686 task capabilities declaration.""" + +from mcp_types import ( + ServerTasksCapability, + ServerTasksRequestsCapability, + TasksCallCapability, + TasksCancelCapability, + TasksListCapability, + TasksToolsCapability, +) + + +def get_task_capabilities() -> ServerTasksCapability | None: + """Return the SEP-1686 task capabilities. + + Returns task capabilities as a first-class ServerCapabilities field, + declaring support for list, cancel, and request operations per SEP-1686. + + Returns None if a compatible pydocket is not installed (no task support). + Uses the canonical ``is_docket_available()`` check so that capability + advertisement and handler registration stay in sync — otherwise a server + with an old transitive pydocket would advertise task support and then + return "method not found" when clients invoked it. + + Only tools are advertised as task-capable. In the SDK v2 b1 wire types, + ``ReadResourceRequestParams`` / ``GetPromptRequestParams`` carry no ``task`` + field (sdk-feedback #3), so resource/prompt task submissions are not + wire-expressible and always graceful-degrade to synchronous execution. + Advertising ``prompts``/``resources`` task support would mislead + capability-discovering clients into sending task-augmented reads/gets that + silently run synchronously. Restore them here once the SDK adds task + metadata to those request params. + """ + # Function-local import to avoid a circular import at module load time: + # fastmcp.server.tasks.__init__ pulls in this module, and dependencies + # transitively reaches back into fastmcp.server.tasks.keys. + from fastmcp.server.dependencies import is_docket_available + + if not is_docket_available(): + return None + + return ServerTasksCapability( + list=TasksListCapability(), + cancel=TasksCancelCapability(), + requests=ServerTasksRequestsCapability( + tools=TasksToolsCapability(call=TasksCallCapability()), + ), + ) diff --git a/fastmcp_slim/fastmcp/server/tasks/config.py b/fastmcp_slim/fastmcp/server/tasks/config.py new file mode 100644 index 000000000..b7fe2c50b --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/config.py @@ -0,0 +1,19 @@ +"""Backward-compatible exports for task configuration primitives.""" + +from fastmcp.utilities.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_TTL_MS, + TaskConfig, + TaskMeta, + TaskMode, +) + +__all__ = [ + "DEFAULT_POLL_INTERVAL", + "DEFAULT_POLL_INTERVAL_MS", + "DEFAULT_TTL_MS", + "TaskConfig", + "TaskMeta", + "TaskMode", +] diff --git a/fastmcp_slim/fastmcp/server/tasks/context.py b/fastmcp_slim/fastmcp/server/tasks/context.py new file mode 100644 index 000000000..8462ad5a7 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/context.py @@ -0,0 +1,363 @@ +"""Task context and scoping for background task execution. + +Determines authorization scope (``get_task_scope``), manages the context +snapshot that is captured at task submission and restored in workers +(``TaskContextSnapshot``), and maintains in-process registries for live +sessions and servers. +""" + +from __future__ import annotations + +import json +import logging +import weakref +from collections import OrderedDict +from contextvars import ContextVar +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix + +try: + from docket import TaskKey +except ImportError: + + def TaskKey() -> str: # type: ignore[no-redef] + # Stub so this module stays importable without the fastmcp[tasks] + # extra. ``restore_task_snapshot`` is only ever invoked inside a + # Docket worker, where the real ``docket.TaskKey`` sentinel is + # always present. + return "" + + +if TYPE_CHECKING: + from docket import Docket + from mcp.server.session import ServerSession + + from fastmcp.server.server import FastMCP + +_logger = logging.getLogger(__name__) + + +def get_task_scope() -> str | None: + """Get the authorization scope for task isolation. + + Returns the raw scope identifier for the current access token, or + ``None`` when no auth context is present (anonymous tasks). + + The scope is composed as ``client_id|sub`` when the token carries a + ``sub`` claim — necessary for fixed-OAuth servers where ``client_id`` is + shared across all users — and falls back to ``client_id`` alone for + DCR/CIMD flows where the client identity is already per-user. + + Encoding for Redis/Docket keys happens at the boundary in ``keys.py``; + this function returns the raw value. + """ + from fastmcp.server.dependencies import get_access_token + + token = get_access_token() + if token is None: + return None + sub = token.claims.get("sub") if token.claims else None + if sub: + return f"{token.client_id}|{sub}" + return token.client_id + + +@dataclass(frozen=True, slots=True) +class TaskContextInfo: + """Information about the current background task context. + + Returned by ``get_task_context()`` when running inside a Docket worker. + Contains identifiers needed to communicate with the MCP session. + """ + + task_id: str + """The MCP task ID (server-generated UUID).""" + + task_scope: str | None + """The authorization scope that owns this task, or ``None`` if anonymous.""" + + +def get_task_context() -> TaskContextInfo | None: + """Get the current task context if running inside a background task worker. + + This function extracts task information from the Docket execution context. + Returns None if not running in a task context (e.g., foreground execution). + + Returns: + TaskContextInfo with task_id and task_scope, or None if not in a task. + """ + from fastmcp.server.dependencies import is_docket_available + + if not is_docket_available(): + return None + + from docket.dependencies import current_execution + + try: + execution = current_execution.get() + key_parts = parse_task_key(execution.key) + return TaskContextInfo( + task_id=key_parts["client_task_id"], + task_scope=key_parts["task_scope"], + ) + except LookupError: + return None + except (ValueError, KeyError): + return None + + +@dataclass(frozen=True, slots=True) +class TaskContextSnapshot: + """All context data snapshotted at task-submission time. + + Stored as a single Redis key per task, restored once in the worker. + """ + + access_token_json: str | None = None + http_headers: dict[str, str] | None = None + origin_request_id: str | None = None + session_id: str | None = None + + @classmethod + def capture(cls) -> TaskContextSnapshot: + """Capture current context for background task execution.""" + from fastmcp.server.dependencies import ( + get_access_token, + get_context, + get_http_headers, + ) + + access_token = get_access_token() + ctx = get_context() + request_context = ctx.request_context + try: + session_id = ctx.session_id + except RuntimeError: + session_id = None + return cls( + access_token_json=( + access_token.model_dump_json() if access_token else None + ), + http_headers=get_http_headers(include_all=True) or None, + origin_request_id=( + str(request_context.request_id) if request_context is not None else None + ), + session_id=session_id, + ) + + @classmethod + def from_json(cls, raw: str | bytes) -> TaskContextSnapshot: + """Deserialize from JSON stored in Redis.""" + if isinstance(raw, bytes): + raw = raw.decode() + parsed = json.loads(raw) + headers = parsed.get("http_headers") + if isinstance(headers, dict): + headers = {str(k).lower(): str(v) for k, v in headers.items()} + return cls( + access_token_json=parsed.get("access_token_json"), + http_headers=headers, + origin_request_id=parsed.get("origin_request_id"), + session_id=parsed.get("session_id"), + ) + + def to_json(self) -> str: + """Serialize to JSON for Redis storage.""" + return json.dumps( + { + "access_token_json": self.access_token_json, + "http_headers": self.http_headers, + "origin_request_id": self.origin_request_id, + "session_id": self.session_id, + } + ) + + async def save( + self, + docket: Docket, + task_scope: str | None, + task_id: str, + ttl_seconds: int, + ) -> None: + """Store this snapshot as a single Redis key.""" + key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot") + async with docket.redis() as redis: + await redis.set(key, self.to_json(), ex=ttl_seconds) + + +# Cache keyed by task_id so stale entries from previous tasks in the same +# asyncio context are automatically ignored (Docket workers may reuse contexts). +_task_snapshot: ContextVar[tuple[str, TaskContextSnapshot] | None] = ContextVar( + "task_snapshot", default=None +) + + +def _remember_snapshot(task_id: str, snapshot: TaskContextSnapshot) -> None: + """Bind a snapshot to the current asyncio context under ``task_id``. + + Nothing outside this task's context sees it; stale entries left in a + reused context are ignored on recall. + """ + _task_snapshot.set((task_id, snapshot)) + + +def _recall_snapshot(task_id: str) -> TaskContextSnapshot | None: + """Return the snapshot bound for ``task_id`` in the current context. + + Returns ``None`` if nothing is bound, or if the bound entry belongs to + a different task (a stale leftover from a reused asyncio context). + """ + cached = _task_snapshot.get() + if cached is not None: + cached_task_id, snapshot = cached + if cached_task_id == task_id: + return snapshot + return None + + +def get_task_session_id() -> str | None: + """Get the session_id for the current background task, if available. + + Reads the cached snapshot set by the worker-level restore dependency. + Returns None if not in a task context or the snapshot wasn't restored. + """ + task_info = get_task_context() + if task_info is None: + return None + snapshot = _recall_snapshot(task_info.task_id) + return snapshot.session_id if snapshot else None + + +async def restore_task_snapshot(key: str = TaskKey()) -> None: + """Worker-level Docket dependency that restores the task-context snapshot. + + Runs before each fastmcp-owned task, populating the snapshot ContextVar + so user code — and any task-scoped dependency like ``_CurrentContext`` — + sees a ready snapshot without touching Redis itself. All Redis I/O + goes through Docket's async client, so cluster URLs and the memory:// + backend work transparently (#3897). Failures are non-fatal: the task + still runs, and sync helpers return ``None`` as they would have before + the snapshot was captured. + """ + try: + parts = parse_task_key(key) + except ValueError: + # Non-fastmcp key (e.g. docket scheduler internals) — nothing to do. + return + + from fastmcp.server.dependencies import _current_docket, get_server + + try: + docket = get_server()._docket + except RuntimeError: + docket = None + if docket is None: + docket = _current_docket.get() + if docket is None: + return + + task_scope = parts["task_scope"] + task_id = parts["client_task_id"] + try: + async with docket.redis() as redis: + raw = await redis.get( + docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot") + ) + if raw is None: + return + _remember_snapshot(task_id, TaskContextSnapshot.from_json(raw)) + except Exception: + _logger.warning("Failed to restore task snapshot for %s", key, exc_info=True) + + +# In-process optimization: when the Docket worker runs in the same process as +# the MCP server, we can hand background tasks a live ServerSession so they can +# call session methods directly (e.g. send_notification). In distributed +# deployments where workers are separate processes, these registries will be +# empty and the worker's Context will have session=None — that's fine, because +# elicitation and notifications have Redis-based fallbacks that work across +# process boundaries (see notifications.py and elicitation.py). + +_task_sessions: dict[str, weakref.ref[ServerSession]] = {} +_TASK_SESSION_CONNECTION_REF = "_fastmcp_task_session_ref" +_TASK_SESSION_CLEANUP_REGISTERED = "_fastmcp_task_session_cleanup_registered" + + +def _remove_task_session(session_id: str, ref: weakref.ref[ServerSession]) -> None: + if _task_sessions.get(session_id) is ref: + _task_sessions.pop(session_id) + + +def register_task_session(session_id: str, session: ServerSession) -> None: + """Register a session for in-process background task access. + + Called automatically when a task is submitted to Docket. The session is + stored as a weakref so it doesn't prevent garbage collection when the + client disconnects. + """ + + session_ref = weakref.ref( + session, lambda ref: _remove_task_session(session_id, ref) + ) + _task_sessions[session_id] = session_ref + + connection = getattr(session, "_connection", None) + if connection is None: + return + + state = connection.state + state[_TASK_SESSION_CONNECTION_REF] = (session_id, session_ref) + if state.get(_TASK_SESSION_CLEANUP_REGISTERED): + return + + def remove_connection_session() -> None: + registered = state.pop(_TASK_SESSION_CONNECTION_REF, None) + if registered is not None: + registered_session_id, registered_ref = registered + _remove_task_session(registered_session_id, registered_ref) + + connection.exit_stack.callback(remove_connection_session) + state[_TASK_SESSION_CLEANUP_REGISTERED] = True + + +def get_task_session(session_id: str) -> ServerSession | None: + """Get a registered session by ID if still alive. + + Returns None in distributed workers where the session lives in another + process — callers must handle this gracefully. + """ + ref = _task_sessions.get(session_id) + if ref is None: + return None + session = ref() + if session is None: + _task_sessions.pop(session_id, None) + return session + + +_task_server_map: OrderedDict[str, weakref.ref[FastMCP]] = OrderedDict() +_TASK_SERVER_MAP_MAX_SIZE = 10_000 + + +def register_task_server(task_id: str, server: FastMCP) -> None: + """Register the server for a background task. + + Called at task-submission time so that background workers can resolve + the correct (child) server for mounted tasks. + """ + _task_server_map[task_id] = weakref.ref(server) + while len(_task_server_map) > _TASK_SERVER_MAP_MAX_SIZE: + _task_server_map.popitem(last=False) + + +def get_task_server(task_id: str) -> FastMCP | None: + """Get the registered server for a background task, if still alive.""" + ref = _task_server_map.get(task_id) + if ref is None: + return None + server = ref() + if server is None: + _task_server_map.pop(task_id, None) + return server diff --git a/fastmcp_slim/fastmcp/server/tasks/elicitation.py b/fastmcp_slim/fastmcp/server/tasks/elicitation.py new file mode 100644 index 000000000..d9a6e6df2 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/elicitation.py @@ -0,0 +1,347 @@ +"""Background task elicitation support (SEP-1686). + +This module provides elicitation capabilities for background tasks running +in Docket workers. Unlike regular MCP requests, background tasks don't have +an active request context, so elicitation requires special handling: + +1. Set task status to "input_required" via Redis +2. Send notifications/tasks/status with elicitation metadata +3. Wait for client to send input via tasks/sendInput +4. Resume task execution with the provided input + +This uses the public MCP SDK APIs where possible, with minimal use of +internal APIs for background task coordination. +""" + +from __future__ import annotations + +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +import mcp_types +from mcp import ServerSession + +from fastmcp.server.tasks.context import get_task_context, get_task_session_id +from fastmcp.server.tasks.keys import task_redis_prefix +from fastmcp.server.tasks.notifications import push_notification + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from fastmcp.server.server import FastMCP + + +# TTL for elicitation state (1 hour) +ELICIT_TTL_SECONDS = 3600 + + +def _elicit_keys(task_scope: str | None, task_id: str) -> tuple[str, str, str]: + """Build (request, response, status) Redis keys for a task's elicitation.""" + prefix = f"{task_redis_prefix(task_scope)}:{task_id}:elicit" + return f"{prefix}:request", f"{prefix}:response", f"{prefix}:status" + + +async def elicit_for_task( + task_id: str, + session: ServerSession | None, + message: str, + schema: dict[str, Any], + fastmcp: FastMCP, +) -> mcp_types.ElicitResult: + """Send an elicitation request from a background task. + + This function handles the complexity of eliciting user input when running + in a Docket worker context where there's no active MCP request. + + Args: + task_id: The background task ID + session: The MCP ServerSession for this task + message: The message to display to the user + schema: The JSON schema for the expected response + fastmcp: The FastMCP server instance + + Returns: + ElicitResult containing the user's response + + Raises: + RuntimeError: If Docket is not available + MCPError: If the elicitation request fails + """ + docket = fastmcp._docket + if docket is None: + raise RuntimeError( + "Background task elicitation requires Docket. " + "Ensure 'fastmcp[tasks]' is installed and the server has task-enabled components." + ) + + # Generate a unique request ID for this elicitation + request_id = str(uuid.uuid4()) + + task_context = get_task_context() + if task_context is not None: + task_scope = task_context.task_scope + # Prefer the live session's cached ID (always available in-process), + # fall back to the snapshot for distributed workers. + session_id = ( + getattr(session, "_fastmcp_state_prefix", None) or get_task_session_id() + ) + else: + raise RuntimeError( + "Cannot determine task scope for elicitation. " + "This typically means elicit_for_task() was called outside a Docket worker context." + ) + + # Store elicitation request in Redis + request_key, response_key, status_key = _elicit_keys(task_scope, task_id) + + elicit_request = { + "request_id": request_id, + "message": message, + "schema": schema, + } + + async with docket.redis() as redis: + # Store the elicitation request + await redis.set( + docket.key(request_key), + json.dumps(elicit_request), + ex=ELICIT_TTL_SECONDS, + ) + # Set status to "waiting" + await redis.set( + docket.key(status_key), + "waiting", + ex=ELICIT_TTL_SECONDS, + ) + + # Send task status update notification with input_required status. + # Use notifications/tasks/status so typed MCP clients can consume it. + # + # NOTE: We use the distributed notification queue instead of session.send_notification() + # This enables notifications to work when workers run in separate processes + # (Azure Web PubSub / Service Bus inspired pattern) + timestamp = datetime.now(timezone.utc).isoformat() + notification_dict = { + "method": "notifications/tasks/status", + "params": { + "taskId": task_id, + "status": "input_required", + "statusMessage": message, + "createdAt": timestamp, + "lastUpdatedAt": timestamp, + "ttl": ELICIT_TTL_SECONDS * 1000, + }, + "_meta": { + "io.modelcontextprotocol/related-task": { + "taskId": task_id, + "status": "input_required", + "statusMessage": message, + "task_scope": task_scope, + "elicitation": { + "requestId": request_id, + "message": message, + "requestedSchema": schema, + }, + } + }, + } + + if session_id is None: + logger.warning( + "No session_id available for task %s, cannot deliver elicitation notification", + task_id, + ) + return mcp_types.ElicitResult(action="cancel", content=None) + + try: + await push_notification(session_id, notification_dict, docket) + except Exception as e: + # Fail fast: if notification can't be queued, client won't know to respond + # Return cancel immediately rather than waiting for 1-hour timeout + logger.warning( + "Failed to queue input_required notification for task %s, cancelling elicitation: %s", + task_id, + e, + ) + # Best-effort cleanup + try: + async with docket.redis() as redis: + await redis.delete( + docket.key(request_key), + docket.key(status_key), + ) + except Exception: + pass # Keys will expire via TTL + return mcp_types.ElicitResult(action="cancel", content=None) + + # Wait for response using BLPOP (blocking pop) + # This is much more efficient than polling - single Redis round-trip + # that blocks until a response is pushed, vs 7,200 round-trips/hour with polling + max_wait_seconds = ELICIT_TTL_SECONDS + + try: + async with docket.redis() as redis: + # BLPOP blocks until an item is pushed to the list or timeout + # Returns tuple of (key, value) or None on timeout + result = await redis.blpop( + [docket.key(response_key)], + timeout=max_wait_seconds, + ) + + if result: + # result is (key, value) tuple + _key, response_data = result + response = json.loads(response_data) + + # Clean up Redis keys + await redis.delete( + docket.key(request_key), + docket.key(status_key), + ) + + # Convert to ElicitResult + return mcp_types.ElicitResult( + action=response.get("action", "accept"), + content=response.get("content"), + ) + except Exception as e: + logger.warning( + "BLPOP failed for task %s elicitation, falling back to cancel: %s", + task_id, + e, + ) + + # Timeout or error - treat as cancellation + # Best-effort cleanup - if Redis is unavailable, keys will expire via TTL + try: + async with docket.redis() as redis: + await redis.delete( + docket.key(request_key), + docket.key(response_key), + docket.key(status_key), + ) + except Exception as cleanup_error: + logger.debug( + "Failed to clean up elicitation keys for task %s (will expire via TTL): %s", + task_id, + cleanup_error, + ) + + return mcp_types.ElicitResult(action="cancel", content=None) + + +async def relay_elicitation( + session: ServerSession, + task_scope: str | None, + task_id: str, + elicitation: dict[str, Any], + fastmcp: FastMCP, +) -> None: + """Relay elicitation from a background task worker to the client. + + Called by the notification subscriber when it detects an input_required + notification with elicitation metadata. Sends a standard elicitation/create + request to the client session, then uses handle_task_input() to push the + response to Redis so the blocked worker can resume. + + Args: + session: MCP ServerSession + task_scope: Authorization scope for Redis key construction + task_id: Background task ID + elicitation: Elicitation metadata (message, requestedSchema) + fastmcp: FastMCP server instance + """ + try: + result = await session.elicit( + message=elicitation["message"], + requested_schema=elicitation["requestedSchema"], + ) + await handle_task_input( + task_id=task_id, + task_scope=task_scope, + action=result.action, + content=result.content, + fastmcp=fastmcp, + ) + logger.debug( + "Relayed elicitation response for task %s (action=%s)", + task_id, + result.action, + ) + except Exception as e: + logger.warning("Failed to relay elicitation for task %s: %s", task_id, e) + # Push a cancel response so the worker's BLPOP doesn't block forever + success = await handle_task_input( + task_id=task_id, + task_scope=task_scope, + action="cancel", + content=None, + fastmcp=fastmcp, + ) + if not success: + logger.warning( + "Failed to push cancel response for task %s " + "(worker may block until TTL)", + task_id, + ) + + +async def handle_task_input( + task_id: str, + task_scope: str | None, + action: str, + content: dict[str, Any] | None, + fastmcp: FastMCP, +) -> bool: + """Handle input sent to a background task via tasks/sendInput. + + This is called when a client sends input in response to an elicitation + request from a background task. + + Args: + task_id: The background task ID + task_scope: Authorization scope for Redis key construction + action: The elicitation action ("accept", "decline", "cancel") + content: The response content (for "accept" action) + fastmcp: The FastMCP server instance + + Returns: + True if the input was successfully stored, False otherwise + """ + docket = fastmcp._docket + if docket is None: + return False + + _, response_key, status_key = _elicit_keys(task_scope, task_id) + + response = { + "action": action, + "content": content, + } + + async with docket.redis() as redis: + # Check if there's a pending elicitation + status = await redis.get(docket.key(status_key)) + if status is None or status.decode("utf-8") != "waiting": + return False + + # Push response to list - this wakes up the BLPOP in elicit_for_task + # Using LPUSH instead of SET enables the efficient blocking wait pattern + await redis.lpush( + docket.key(response_key), + json.dumps(response), + ) + # Set TTL on the response list (in case BLPOP doesn't consume it) + await redis.expire(docket.key(response_key), ELICIT_TTL_SECONDS) + + # Update status to "responded" + await redis.set( + docket.key(status_key), + "responded", + ex=ELICIT_TTL_SECONDS, + ) + + return True diff --git a/fastmcp_slim/fastmcp/server/tasks/handlers.py b/fastmcp_slim/fastmcp/server/tasks/handlers.py new file mode 100644 index 000000000..4b2ac1740 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/handlers.py @@ -0,0 +1,263 @@ +"""SEP-1686 task execution handlers. + +Handles queuing tool/prompt/resource executions to Docket as background tasks. +""" + +from __future__ import annotations + +import asyncio +import uuid +from contextlib import suppress +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Literal + +import mcp_types +from mcp.shared.exceptions import MCPError +from mcp_types import INTERNAL_ERROR + +from fastmcp.server.dependencies import ( + _current_docket, + get_context, +) +from fastmcp.server.tasks.config import TaskMeta +from fastmcp.server.tasks.context import ( + TaskContextSnapshot, + get_task_scope, + register_task_server, + register_task_session, +) +from fastmcp.server.tasks.keys import build_task_key, task_redis_prefix +from fastmcp.tools.function_tool import _strict_input_validation +from fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource + from fastmcp.resources.template import ResourceTemplate + from fastmcp.tools.base import Tool + +logger = get_logger(__name__) + +# Redis mapping TTL buffer: Add 15 minutes to Docket's execution_ttl +TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60 + + +async def submit_to_docket( + task_type: Literal["tool", "resource", "template", "prompt"], + key: str, + component: Tool | Resource | ResourceTemplate | Prompt, + arguments: dict[str, Any] | None = None, + task_meta: TaskMeta | None = None, +) -> mcp_types.CreateTaskResult: + """Submit any component to Docket for background execution (SEP-1686). + + Unified handler for all component types. Called by component's internal + methods (_run, _read, _render) when task metadata is present and mode allows. + + Queues the component's method to Docket, stores raw return values, + and converts to MCP types on retrieval. + + Args: + task_type: Component type for task key construction + key: The component key as seen by MCP layer (with namespace prefix) + component: The component instance (Tool, Resource, ResourceTemplate, Prompt) + arguments: Arguments/params (None for Resource which has no args) + task_meta: Task execution metadata. If task_meta.ttl is provided, it + overrides the server default (docket.execution_ttl). + + Returns: + CreateTaskResult: Task stub with proper Task object + """ + # Validate and coerce arguments before creating any task state. A failure + # here must surface before the Redis metadata and initial "working" + # notification below are written, otherwise an invalid input would orphan a + # task the client has already observed (#4349). + # + # Honor the server's strict_input_validation setting so a strict tool + # rejects lax coercions (e.g. {"n": "1"} for n: int) at submission just as + # it does on the synchronous call path — otherwise task=True would bypass + # strict validation entirely. + if arguments is not None: + arguments = component.coerce_task_arguments( + arguments, strict=_strict_input_validation() + ) + + # Generate server-side task ID per SEP-1686 final spec (line 375-377) + # Server MUST generate task IDs, clients no longer provide them + server_task_id = str(uuid.uuid4()) + + # Record creation timestamp per SEP-1686 final spec (line 430). SDK v2 + # types `Task.created_at` / `TaskStatusNotificationParams.created_at` as ISO + # strings, so carry a serialized copy for wire-crossing models. + created_at = datetime.now(timezone.utc) + created_at_iso = created_at.isoformat() + + ctx = get_context() + + # Authorization scope for task isolation (auth identity, or None for anonymous) + task_scope = get_task_scope() + + # Transport session ID for notification delivery + try: + session_id = ctx.session_id + except RuntimeError: + session_id = None + + # Try the server's own Docket first; fall back to the ContextVar for + # mounted children (whose parent server owns the Docket instance). + docket = ctx.fastmcp._docket or _current_docket.get() + if docket is None: + raise MCPError( + code=INTERNAL_ERROR, + message="Background tasks require a running FastMCP server context", + ) + + # Register the current server so background workers resolve + # CurrentFastMCP() / ctx.fastmcp to the correct (child) server + # for mounted tasks. At this point ctx.fastmcp is the child because + # we're inside the child's call_tool dispatch. + register_task_server(server_task_id, ctx.fastmcp) + + # Build full task key with embedded metadata + task_key = build_task_key(task_scope, server_task_id, task_type, key) + + # Determine TTL: use task_meta.ttl if provided, else docket default + if task_meta is not None and task_meta.ttl is not None: + ttl_ms = task_meta.ttl + else: + ttl_ms = int(docket.execution_ttl.total_seconds() * 1000) + ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS + + # Store task metadata in Redis for protocol handlers + prefix = task_redis_prefix(task_scope) + task_meta_key = docket.key(f"{prefix}:{server_task_id}") + created_at_key = docket.key(f"{prefix}:{server_task_id}:created_at") + poll_interval_key = docket.key(f"{prefix}:{server_task_id}:poll_interval") + poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000) + + # Snapshot all context (access token, headers, origin request ID, + # and session_id for notification delivery in background workers) + snapshot = TaskContextSnapshot.capture() + + async with docket.redis() as redis: + await redis.set(task_meta_key, task_key, ex=ttl_seconds) + await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds) + await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds) + + await snapshot.save(docket, task_scope, server_task_id, ttl_seconds) + + # Register session for Context access in background workers (SEP-1686) + # This enables elicitation/sampling from background tasks via weakref + # Skip when there is no session (programmatic calls without MCP session) + if session_id is not None: + register_task_session(session_id, ctx.session) + + # Send an initial tasks/status notification before queueing. + # This guarantees clients can observe task creation immediately. + notification = mcp_types.TaskStatusNotification.model_validate( + { + "method": "notifications/tasks/status", + "params": { + "taskId": server_task_id, + "status": "working", + "statusMessage": "Task submitted", + "createdAt": created_at_iso, + "lastUpdatedAt": created_at_iso, + "ttl": ttl_ms, + "pollInterval": poll_interval_ms, + }, + "_meta": { + "io.modelcontextprotocol/related-task": { + "taskId": server_task_id, + } + }, + } + ) + # SDK v2: `ServerNotification` is a union type, not a wrapper class; + # `send_notification` takes the bare notification model directly. + with suppress(Exception): + # Don't let notification failures break task creation + await ctx.session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + # Queue function to Docket by key (result storage via execution_ttl) + # Use component.add_to_docket() which handles calling conventions + # `fn_key` is the function lookup key (e.g., "child_multiply") + # `task_key` is the task result key (e.g., "fastmcp:task:{task_scope}:{task_id}:tool:child_multiply") + # Resources don't take arguments; tools/prompts/templates always pass arguments (even if None/empty) + if task_type == "resource": + await component.add_to_docket(docket, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[missing-argument] + else: + await component.add_to_docket(docket, arguments, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[invalid-argument-type, too-many-positional-arguments] + + # Spawn subscription task to send status notifications (SEP-1686 optional feature). + # SDK v2 constructs a ServerSession per request and exposes no per-connection + # task group, so the subscription runs as a standalone asyncio task that + # outlives the submitting request; it is cancelled when the connection closes. + # Deferred: subscriptions and notifications depend on docket at import time + from fastmcp.server.tasks.subscriptions import subscribe_to_task_updates + + subscription_task = asyncio.create_task( + subscribe_to_task_updates( + server_task_id, + task_key, + ctx.session, + docket, + poll_interval_ms, + ), + name=f"task-subscription-{server_task_id[:8]}", + ) + connection = getattr(ctx.session, "_connection", None) + if connection is not None: + + async def _cancel_subscription() -> None: + if not subscription_task.done(): + subscription_task.cancel() + with suppress(asyncio.CancelledError): + await subscription_task + + connection.exit_stack.push_async_callback(_cancel_subscription) + + # Deferred: notifications depends on docket at import time + from fastmcp.server.tasks.notifications import ( + ensure_subscriber_running, + stop_subscriber, + ) + + if session_id is not None: + try: + await ensure_subscriber_running( + session_id, ctx.session, docket, ctx.fastmcp + ) + + # Register cleanup callback on connection exit (once per session). + # SDK v2 constructs ServerSession per request, so the stable + # per-connection lifecycle hook lives on the underlying Connection + # (`connection.exit_stack`), not the session. The registration flag + # is likewise stashed on the connection's `state` so it survives + # across requests. + connection = getattr(ctx.session, "_connection", None) + if connection is not None and not connection.state.get( + "_notification_cleanup_registered" + ): + + async def _cleanup_subscriber() -> None: + await stop_subscriber(session_id) # type: ignore[arg-type] + + connection.exit_stack.push_async_callback(_cleanup_subscriber) + connection.state["_notification_cleanup_registered"] = True + except Exception as e: + # Non-fatal: elicitation will still work via polling fallback + logger.debug("Failed to start notification subscriber: %s", e) + + # Return CreateTaskResult with proper Task object + # Tasks MUST begin in "working" status per SEP-1686 final spec (line 381) + return mcp_types.CreateTaskResult( + task=mcp_types.Task( + task_id=server_task_id, + status="working", + created_at=created_at_iso, + last_updated_at=created_at_iso, + ttl=ttl_ms, + poll_interval=poll_interval_ms, + ) + ) diff --git a/fastmcp_tasks/fastmcp_tasks/keys.py b/fastmcp_slim/fastmcp/server/tasks/keys.py similarity index 75% rename from fastmcp_tasks/fastmcp_tasks/keys.py rename to fastmcp_slim/fastmcp/server/tasks/keys.py index e0bc4ae56..10af6a6f9 100644 --- a/fastmcp_tasks/fastmcp_tasks/keys.py +++ b/fastmcp_slim/fastmcp/server/tasks/keys.py @@ -37,44 +37,6 @@ _AUTH_TAG = "auth" _ANON_TAG = "anon" _VALID_TAGS = (_AUTH_TAG, _ANON_TAG) -# Delimiter separating the stable base task key from a per-leg suffix. A single -# background task runs as a sequence of Docket executions (legs): the first leg -# uses the base key, and each re-entry (after the client answers input) enqueues -# a fresh execution under `{base}{_LEG_DELIMITER}{n}`. The base key encodes every -# segment with `quote(safe="")`, which percent-encodes `#` to `%23`, so a literal -# `#` never appears inside the base key and is an unambiguous leg boundary. All -# task-identity parsing strips the leg suffix, so the scope/task-id/component a -# leg resolves to are identical across every leg of the same task. -_LEG_DELIMITER = "#" - - -def leg_execution_key(base_task_key: str, leg: int) -> str: - """Build the Docket execution key for a given leg of a task. - - Leg 1 uses the bare base key (so existing single-leg behavior is unchanged); - later legs append `#leg{n}` so each re-entry is a distinct Docket execution - while still parsing back to the same task scope, id, and component. - """ - if leg <= 1: - return base_task_key - return f"{base_task_key}{_LEG_DELIMITER}leg{leg}" - - -def base_task_key(execution_key: str) -> str: - """Strip any per-leg suffix, returning the stable base task key.""" - return execution_key.split(_LEG_DELIMITER, 1)[0] - - -def leg_number_from_key(execution_key: str) -> int: - """Return the leg number a Docket execution key encodes (leg 1 = base key).""" - _base, sep, suffix = execution_key.partition(_LEG_DELIMITER) - if not sep: - return 1 - try: - return int(suffix.removeprefix("leg")) - except ValueError: - return 1 - def build_task_key( task_scope: str | None, @@ -135,9 +97,6 @@ def parse_task_key(task_key: str) -> TaskKeyParts: >>> parse_task_key("anon:task456:tool:my_tool") `{'task_scope': None, 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` """ - # A per-leg execution key (`{base}#leg{n}`) parses to the same identity as - # its base: every leg of a task shares one scope, id, and component. - task_key = base_task_key(task_key) tag, _, rest = task_key.partition(":") if tag not in _VALID_TAGS or not rest: raise ValueError( diff --git a/fastmcp_slim/fastmcp/server/tasks/notifications.py b/fastmcp_slim/fastmcp/server/tasks/notifications.py new file mode 100644 index 000000000..9a662cd95 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/notifications.py @@ -0,0 +1,312 @@ +"""Distributed notification queue for background task events (SEP-1686). + +Enables distributed Docket workers to send MCP notifications to clients +without holding session references. Workers push to a Redis queue, +the MCP server process subscribes and forwards to the client's session. + +Pattern: Fire-and-forward with retry +- One queue per session_id +- LPUSH/BRPOP for reliable ordered delivery +- Retry up to 3 times on delivery failure, then discard +- TTL-based expiration for stale messages + +Note: Docket's execution.subscribe() handles task state/progress events via +Redis Pub/Sub. This module handles elicitation-specific notifications that +require reliable delivery (input_required prompts, cancel signals). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import weakref +from contextlib import suppress +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +import mcp_types + +if TYPE_CHECKING: + from docket import Docket + from mcp.server.session import ServerSession + + from fastmcp.server.server import FastMCP + +logger = logging.getLogger(__name__) + +# Redis key patterns +NOTIFICATION_QUEUE_KEY = "fastmcp:notifications:{session_id}" +NOTIFICATION_ACTIVE_KEY = "fastmcp:notifications:{session_id}:active" + +# Configuration +NOTIFICATION_TTL_SECONDS = 300 # 5 minute message TTL (elicitation response window) +MAX_DELIVERY_ATTEMPTS = 3 # Retry failed deliveries before discarding +SUBSCRIBER_TIMEOUT_SECONDS = 30 # BRPOP timeout (also heartbeat interval) + + +async def push_notification( + session_id: str, + notification: dict[str, Any], + docket: Docket, +) -> None: + """Push notification to session's queue (called from Docket worker). + + Used for elicitation-specific notifications (input_required, cancel) + that need reliable delivery across distributed processes. + + Args: + session_id: Target session's identifier + notification: MCP notification dict (method, params, _meta) + docket: Docket instance for Redis access + """ + key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) + message = json.dumps( + { + "notification": notification, + "attempt": 0, + "enqueued_at": datetime.now(timezone.utc).isoformat(), + } + ) + async with docket.redis() as redis: + await redis.lpush(key, message) + await redis.expire(key, NOTIFICATION_TTL_SECONDS) + + +async def notification_subscriber_loop( + session_id: str, + session: ServerSession, + docket: Docket, + fastmcp: FastMCP, +) -> None: + """Subscribe to notification queue and forward to session. + + Runs in the MCP server process. Bridges distributed workers to clients. + + This loop: + 1. Maintains a heartbeat (active subscriber marker for debugging) + 2. Blocks on BRPOP waiting for notifications + 3. Forwards notifications to the client's session + 4. Retries failed deliveries, then discards (no dead-letter queue) + + Args: + session_id: Session identifier to subscribe to + session: MCP ServerSession for sending notifications + docket: Docket instance for Redis access + fastmcp: FastMCP server instance (for elicitation relay) + """ + queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) + active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id)) + + logger.debug("Starting notification subscriber for session %s", session_id) + + while True: + try: + async with docket.redis() as redis: + # Heartbeat: mark subscriber as active (for distributed debugging) + await redis.set(active_key, "1", ex=SUBSCRIBER_TIMEOUT_SECONDS * 2) + + # Blocking wait for notification (timeout refreshes heartbeat) + # Using BRPOP (right pop) for FIFO order with LPUSH (left push) + result = await redis.brpop( + [queue_key], timeout=SUBSCRIBER_TIMEOUT_SECONDS + ) + if not result: + continue # Timeout - refresh heartbeat and retry + + _, message_bytes = result + message = json.loads(message_bytes) + notification_dict = message["notification"] + attempt = message.get("attempt", 0) + + try: + # Reconstruct and send MCP notification + await _send_mcp_notification( + session, notification_dict, session_id, docket, fastmcp + ) + logger.debug( + "Delivered notification to session %s (attempt %d)", + session_id, + attempt + 1, + ) + except Exception as send_error: + # Delivery failed - retry or discard + if attempt < MAX_DELIVERY_ATTEMPTS - 1: + # Re-queue with incremented attempt (back of queue) + message["attempt"] = attempt + 1 + message["last_error"] = str(send_error) + await redis.lpush(queue_key, json.dumps(message)) + logger.debug( + "Requeued notification for session %s (attempt %d): %s", + session_id, + attempt + 2, + send_error, + ) + else: + # Discard after max attempts (session likely disconnected) + logger.warning( + "Discarding notification for session %s after %d attempts: %s", + session_id, + MAX_DELIVERY_ATTEMPTS, + send_error, + ) + + except asyncio.CancelledError: + # Graceful shutdown - leave pending messages in queue for reconnect + logger.debug("Notification subscriber cancelled for session %s", session_id) + break + except Exception as e: + logger.debug( + "Notification subscriber error for session %s: %s", session_id, e + ) + await asyncio.sleep(1) # Backoff on error + + +async def _send_mcp_notification( + session: ServerSession, + notification_dict: dict[str, Any], + session_id: str, + docket: Docket, + fastmcp: FastMCP, +) -> None: + """Reconstruct MCP notification from dict and send to session. + + For input_required notifications with elicitation metadata, also sends + a standard elicitation/create request to the client and relays the + response back to the worker via Redis. + + Args: + session: MCP ServerSession + notification_dict: Notification as dict (method, params, _meta) + session_id: Session identifier (for elicitation relay) + docket: Docket instance (for notification delivery) + fastmcp: FastMCP server instance (for elicitation relay) + """ + method = notification_dict.get("method", "notifications/tasks/status") + if method != "notifications/tasks/status": + raise ValueError(f"Unsupported notification method for subscriber: {method}") + + # SDK v2: a notification's `_meta` lives on its params (`params._meta`), not + # at the notification envelope level, so nest it under params before parsing. + params_dict = dict(notification_dict.get("params", {})) + meta_dict = notification_dict.get("_meta") + if meta_dict is not None: + params_dict["_meta"] = meta_dict + notification = mcp_types.TaskStatusNotification.model_validate( + { + "method": "notifications/tasks/status", + "params": params_dict, + } + ) + # SDK v2: `ServerNotification` is a union type; send the bare model. + await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + # If this is an input_required notification with elicitation metadata, + # relay the elicitation to the client via standard elicitation/create + params = notification_dict.get("params", {}) + if params.get("status") == "input_required": + meta = notification_dict.get("_meta", {}) + related_task = meta.get("io.modelcontextprotocol/related-task", {}) + elicitation = related_task.get("elicitation") + if elicitation: + task_id = params.get("taskId") + if not task_id: + logger.warning( + "input_required notification missing taskId, skipping relay" + ) + return + if "task_scope" not in related_task: + logger.warning( + "input_required notification for task %s missing task_scope " + "metadata, skipping elicitation relay", + task_id, + ) + return + task_scope = related_task["task_scope"] + from fastmcp.server.tasks.elicitation import relay_elicitation + + task = asyncio.create_task( + relay_elicitation(session, task_scope, task_id, elicitation, fastmcp), + name=f"elicitation-relay-{task_id[:8]}", + ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + + +# ============================================================================= +# Subscriber Management +# ============================================================================= + +# Strong references to fire-and-forget relay tasks (prevent GC mid-flight) +_background_tasks: set[asyncio.Task[None]] = set() + +# Registry of active subscribers per session (prevents duplicates) +# Uses weakref to session to detect disconnects +_active_subscribers: dict[ + str, tuple[asyncio.Task[None], weakref.ref[ServerSession]] +] = {} + + +async def ensure_subscriber_running( + session_id: str, + session: ServerSession, + docket: Docket, + fastmcp: FastMCP, +) -> None: + """Start notification subscriber if not already running (idempotent). + + Subscriber is created on first task submission and cleaned up on disconnect. + Safe to call multiple times for the same session. + + Args: + session_id: Session identifier + session: MCP ServerSession + docket: Docket instance + fastmcp: FastMCP server instance (for elicitation relay) + """ + # Check if subscriber already running for this session + if session_id in _active_subscribers: + task, session_ref = _active_subscribers[session_id] + # Check if task is still running AND session is still alive + if not task.done() and session_ref() is not None: + return # Already running + + # Task finished or session dead - clean up + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + del _active_subscribers[session_id] + + # Start new subscriber task + task = asyncio.create_task( + notification_subscriber_loop(session_id, session, docket, fastmcp), + name=f"notification-subscriber-{session_id[:8]}", + ) + _active_subscribers[session_id] = (task, weakref.ref(session)) + logger.debug("Started notification subscriber for session %s", session_id) + + +async def stop_subscriber(session_id: str) -> None: + """Stop notification subscriber for a session. + + Called when session disconnects. Pending messages remain in queue + for delivery if client reconnects (with TTL expiration). + + Args: + session_id: Session identifier + """ + if session_id not in _active_subscribers: + return + + task, _ = _active_subscribers.pop(session_id) + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + logger.debug("Stopped notification subscriber for session %s", session_id) + + +def get_subscriber_count() -> int: + """Get number of active subscribers (for monitoring).""" + return len(_active_subscribers) diff --git a/fastmcp_slim/fastmcp/server/tasks/requests.py b/fastmcp_slim/fastmcp/server/tasks/requests.py new file mode 100644 index 000000000..0cbc77aca --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/requests.py @@ -0,0 +1,456 @@ +"""SEP-1686 task request handlers. + +Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel. +These handlers query and manage existing tasks (contrast with handlers.py which creates tasks). + +This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any, Literal + +import mcp_types +from docket.execution import ExecutionState +from mcp.shared.exceptions import MCPError +from mcp_types import ( + INTERNAL_ERROR, + INVALID_PARAMS, + CancelTaskResult, + GetTaskResult, + ListTasksResult, +) + +import fastmcp.server.context +from fastmcp.exceptions import NotFoundError +from fastmcp.prompts.base import Prompt +from fastmcp.resources.base import Resource +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS +from fastmcp.server.tasks.context import get_task_scope +from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix +from fastmcp.tools.base import Tool +from fastmcp.utilities.versions import VersionSpec + +if TYPE_CHECKING: + from fastmcp.server.server import FastMCP + + +# Map Docket execution states to MCP task status strings +# Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status +DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = { + ExecutionState.SCHEDULED: "working", # Initial state per spec + ExecutionState.QUEUED: "working", # Initial state per spec + ExecutionState.RUNNING: "working", + ExecutionState.COMPLETED: "completed", + ExecutionState.FAILED: "failed", + ExecutionState.CANCELLED: "cancelled", +} + + +def _normalize_iso_timestamp(stored: str | None) -> str: + """Return an ISO 8601 timestamp string for a Task's createdAt/lastUpdatedAt. + + The v2 Task model types these fields as ISO 8601 strings. `stored` is the + value read from Redis (already an ISO string) or None; either way this + returns a valid ISO string, falling back to the current UTC time. + """ + if stored: + try: + return datetime.fromisoformat(stored.replace("Z", "+00:00")).isoformat() + except (ValueError, AttributeError): + pass + return datetime.now(timezone.utc).isoformat() + + +def _parse_key_version(key_suffix: str) -> tuple[str, str | None]: + """Parse a key suffix into (name_or_uri, version). + + Keys always contain @ as a version delimiter (sentinel pattern): + - "add@1.0" → ("add", "1.0") # versioned + - "add@" → ("add", None) # unversioned + - "user@example.com@1.0" → ("user@example.com", "1.0") # @ in URI + + Uses rsplit to split on the LAST @ which is always the version delimiter. + Falls back to treating the whole string as the name if @ is not present + (for backwards compatibility with legacy task keys). + """ + if "@" not in key_suffix: + # Legacy key without version sentinel - treat as unversioned + return key_suffix, None + name_or_uri, version = key_suffix.rsplit("@", 1) + return name_or_uri, version if version else None + + +async def _lookup_task_execution( + docket: Any, + task_scope: str | None, + client_task_id: str, +) -> tuple[Any, str | None, int]: + """Look up task execution and metadata from Redis. + + Consolidates the common pattern of fetching task metadata from Redis, + validating it exists, and retrieving the Docket execution. + + Args: + docket: Docket instance + task_scope: Authorization scope + client_task_id: Client-provided task ID + + Returns: + Tuple of (execution, created_at, poll_interval_ms) + + Raises: + MCPError: If task not found or execution not found + """ + prefix = task_redis_prefix(task_scope) + task_meta_key = docket.key(f"{prefix}:{client_task_id}") + created_at_key = docket.key(f"{prefix}:{client_task_id}:created_at") + poll_interval_key = docket.key(f"{prefix}:{client_task_id}:poll_interval") + + # Fetch metadata (single round-trip with mget) + async with docket.redis() as redis: + task_key_bytes, created_at_bytes, poll_interval_bytes = await redis.mget( + task_meta_key, created_at_key, poll_interval_key + ) + + # Decode and validate task_key + task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None + if not task_key: + raise MCPError(code=INVALID_PARAMS, message=f"Task {client_task_id} not found") + + # Get execution + execution = await docket.get_execution(task_key) + if not execution: + raise MCPError( + code=INVALID_PARAMS, + message=f"Task {client_task_id} execution not found", + ) + + # Parse metadata with defaults + created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None + try: + poll_interval_ms = ( + int(poll_interval_bytes.decode("utf-8")) + if poll_interval_bytes + else DEFAULT_POLL_INTERVAL_MS + ) + except (ValueError, UnicodeDecodeError): + poll_interval_ms = DEFAULT_POLL_INTERVAL_MS + + return execution, created_at, poll_interval_ms + + +async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult: + """Handle MCP 'tasks/get' request (SEP-1686). + + Args: + server: FastMCP server instance + params: Request params containing taskId + + Returns: + GetTaskResult: Task status response with spec-compliant fields + """ + async with fastmcp.server.context.Context(fastmcp=server): + client_task_id = params.get("taskId") + if not client_task_id: + raise MCPError( + code=INVALID_PARAMS, message="Missing required parameter: taskId" + ) + + # Get authorization scope for task lookup + task_scope = get_task_scope() + + # Get Docket instance + docket = server._docket + if docket is None: + raise MCPError( + code=INTERNAL_ERROR, + message="Background tasks require Docket", + ) + + # Look up task execution and metadata + execution, created_at, poll_interval_ms = await _lookup_task_execution( + docket, task_scope, client_task_id + ) + + # Sync state from Redis + await execution.sync() + + # Map Docket state to MCP state + state_map = DOCKET_TO_MCP_STATE + mcp_state: Literal[ + "working", "input_required", "completed", "failed", "cancelled" + ] = state_map.get(execution.state, "failed") # type: ignore[assignment] # ty:ignore[invalid-assignment] + + # Build response (use default ttl since we don't track per-task values) + # createdAt is REQUIRED per SEP-1686 final spec (line 430) + # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/get + error_message = None + status_message = None + + if execution.state == ExecutionState.FAILED: + try: + await execution.get_result(timeout=timedelta(seconds=0)) + except Exception as error: + error_message = str(error) + status_message = f"Task failed: {error_message}" + elif execution.progress and execution.progress.message: + # Extract progress message from Docket if available (spec line 403) + status_message = execution.progress.message + + # createdAt is required per spec, but can be None from Redis. The v2 + # Task model types createdAt/lastUpdatedAt as ISO 8601 strings, so + # normalize the stored value (or fall back to now) to an ISO string. + created_at_iso = _normalize_iso_timestamp(created_at) + + return GetTaskResult( + task_id=client_task_id, + status=mcp_state, + created_at=created_at_iso, + last_updated_at=datetime.now(timezone.utc).isoformat(), + ttl=DEFAULT_TTL_MS, + poll_interval=poll_interval_ms, + status_message=status_message, + ) + + +async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: + """Handle MCP 'tasks/result' request (SEP-1686). + + Converts raw task return values to MCP types based on task type. + + Args: + server: FastMCP server instance + params: Request params containing taskId + + Returns: + MCP result (CallToolResult, GetPromptResult, or ReadResourceResult) + """ + async with fastmcp.server.context.Context(fastmcp=server): + client_task_id = params.get("taskId") + if not client_task_id: + raise MCPError( + code=INVALID_PARAMS, message="Missing required parameter: taskId" + ) + + # Get authorization scope for task lookup + task_scope = get_task_scope() + + # Get execution from Docket (use instance attribute for cross-task access) + docket = server._docket + if docket is None: + raise MCPError( + code=INTERNAL_ERROR, + message="Background tasks require Docket", + ) + + # Look up full task key from Redis + task_meta_key = docket.key(f"{task_redis_prefix(task_scope)}:{client_task_id}") + async with docket.redis() as redis: + task_key_bytes = await redis.get(task_meta_key) + + task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8") + + if task_key is None: + raise MCPError( + code=INVALID_PARAMS, + message=f"Invalid taskId: {client_task_id} not found", + ) + + execution = await docket.get_execution(task_key) + if execution is None: + raise MCPError( + code=INVALID_PARAMS, + message=f"Invalid taskId: {client_task_id} not found", + ) + + # Sync state from Redis + await execution.sync() + + # Check if completed + state_map = DOCKET_TO_MCP_STATE + if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED): + mcp_state = state_map.get(execution.state, "failed") + raise MCPError( + code=INVALID_PARAMS, + message=f"Task not completed yet (current state: {mcp_state})", + ) + + # Get result from Docket + try: + raw_value = await execution.get_result(timeout=timedelta(seconds=0)) + except Exception as error: + # Task failed - return error result + return mcp_types.CallToolResult( + content=[mcp_types.TextContent(type="text", text=str(error))], + is_error=True, + _meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field + "io.modelcontextprotocol/related-task": { + "taskId": client_task_id, + } + }, + ) + + # Parse task key to get component key + key_parts = parse_task_key(task_key) + component_key = key_parts["component_identifier"] + + # Look up component by its prefixed key (inlined from deleted get_component) + component: Tool | Resource | ResourceTemplate | Prompt | None = None + try: + if component_key.startswith("tool:"): + name, version_str = _parse_key_version(component_key[5:]) + version = VersionSpec(eq=version_str) if version_str else None + component = await server.get_tool(name, version) + elif component_key.startswith("resource:"): + uri, version_str = _parse_key_version(component_key[9:]) + version = VersionSpec(eq=version_str) if version_str else None + component = await server.get_resource(uri, version) + elif component_key.startswith("template:"): + uri, version_str = _parse_key_version(component_key[9:]) + version = VersionSpec(eq=version_str) if version_str else None + component = await server.get_resource_template(uri, version) + elif component_key.startswith("prompt:"): + name, version_str = _parse_key_version(component_key[7:]) + version = VersionSpec(eq=version_str) if version_str else None + component = await server.get_prompt(name, version) + except NotFoundError: + component = None + + if component is None: + raise MCPError( + code=INTERNAL_ERROR, + message=f"Component not found for task: {component_key}", + ) + + # Build related-task metadata + related_task_meta = { + "io.modelcontextprotocol/related-task": { + "taskId": client_task_id, + } + } + + # Convert based on component type. + # Each branch merges related_task_meta with any existing _meta + # (e.g. fastmcp.wrap_result) rather than overwriting it. + if isinstance(component, Tool): + fastmcp_result = component.convert_result(raw_value) + mcp_result = fastmcp_result.to_mcp_result() + if isinstance(mcp_result, mcp_types.CallToolResult): + merged = {**(mcp_result.meta or {}), **related_task_meta} + mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + elif isinstance(mcp_result, tuple): + content, structured_content = mcp_result + mcp_result = mcp_types.CallToolResult( + content=content, + structured_content=structured_content, + _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + ) + else: + mcp_result = mcp_types.CallToolResult( + content=mcp_result, + _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + ) + return mcp_result + + elif isinstance(component, Prompt): + fastmcp_result = component.convert_result(raw_value) + mcp_result = fastmcp_result.to_mcp_prompt_result() + merged = {**(mcp_result.meta or {}), **related_task_meta} + mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + return mcp_result + + elif isinstance(component, ResourceTemplate): + fastmcp_result = component.convert_result(raw_value) + mcp_result = fastmcp_result.to_mcp_result(component.uri_template) + merged = {**(mcp_result.meta or {}), **related_task_meta} + mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + return mcp_result + + elif isinstance(component, Resource): + fastmcp_result = component.convert_result(raw_value) + mcp_result = fastmcp_result.to_mcp_result(str(component.uri)) + merged = {**(mcp_result.meta or {}), **related_task_meta} + mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + return mcp_result + + else: + raise MCPError( + code=INTERNAL_ERROR, + message=f"Internal error: Unknown component type: {type(component).__name__}", + ) + + +async def tasks_list_handler( + server: FastMCP, params: dict[str, Any] +) -> ListTasksResult: + """Handle MCP 'tasks/list' request (SEP-1686). + + Note: With client-side tracking, this returns minimal info. + + Args: + server: FastMCP server instance + params: Request params (cursor, limit) + + Returns: + ListTasksResult: Response with tasks list and pagination + """ + # Return empty list - client tracks tasks locally + return ListTasksResult(tasks=[], next_cursor=None) + + +async def tasks_cancel_handler( + server: FastMCP, params: dict[str, Any] +) -> CancelTaskResult: + """Handle MCP 'tasks/cancel' request (SEP-1686). + + Cancels a running task, transitioning it to cancelled state. + + Args: + server: FastMCP server instance + params: Request params containing taskId + + Returns: + CancelTaskResult: Task status response showing cancelled state + """ + async with fastmcp.server.context.Context(fastmcp=server): + client_task_id = params.get("taskId") + if not client_task_id: + raise MCPError( + code=INVALID_PARAMS, message="Missing required parameter: taskId" + ) + + # Get authorization scope for task lookup + task_scope = get_task_scope() + + # Get Docket instance + docket = server._docket + if docket is None: + raise MCPError( + code=INTERNAL_ERROR, + message="Background tasks require Docket", + ) + + # Look up task execution and metadata + execution, created_at, poll_interval_ms = await _lookup_task_execution( + docket, task_scope, client_task_id + ) + + # Cancel via Docket (now sets CANCELLED state natively) + # Note: We need to get task_key from execution.key for cancellation + await docket.cancel(execution.key) + + # Return task status with cancelled state + # createdAt is REQUIRED per SEP-1686 final spec (line 430) + # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/cancel + return CancelTaskResult( + task_id=client_task_id, + status="cancelled", + created_at=_normalize_iso_timestamp(created_at), + last_updated_at=datetime.now(timezone.utc).isoformat(), + ttl=DEFAULT_TTL_MS, + poll_interval=poll_interval_ms, + status_message="Task cancelled", + ) diff --git a/fastmcp_slim/fastmcp/server/tasks/routing.py b/fastmcp_slim/fastmcp/server/tasks/routing.py new file mode 100644 index 000000000..97839eff3 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/routing.py @@ -0,0 +1,72 @@ +"""Task routing helper for MCP components. + +Provides unified task mode enforcement and docket routing logic. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +import mcp_types +from mcp.shared.exceptions import MCPError +from mcp_types import METHOD_NOT_FOUND + +from fastmcp.server.tasks.config import TaskMeta +from fastmcp.server.tasks.handlers import submit_to_docket + +if TYPE_CHECKING: + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource + from fastmcp.resources.template import ResourceTemplate + from fastmcp.tools.base import Tool + +TaskType = Literal["tool", "resource", "template", "prompt"] + + +async def check_background_task( + component: Tool | Resource | ResourceTemplate | Prompt, + task_type: TaskType, + arguments: dict[str, Any] | None = None, + task_meta: TaskMeta | None = None, +) -> mcp_types.CreateTaskResult | None: + """Check task mode and submit to background if requested. + + Args: + component: The MCP component + task_type: Type of task ("tool", "resource", "template", "prompt") + arguments: Arguments for tool/prompt/template execution + task_meta: Task execution metadata. If provided, execute as background task. + + Returns: + CreateTaskResult if submitted to docket, None for sync execution + + Raises: + MCPError: If mode="required" but no task metadata, or mode="forbidden" + but task metadata is present + """ + task_config = component.task_config + + # Infer label from component + entity_label = f"{type(component).__name__} '{component.title or component.key}'" + + # Enforce mode="required" - must have task metadata + if task_config.mode == "required" and not task_meta: + raise MCPError( + code=METHOD_NOT_FOUND, + message=f"{entity_label} requires task-augmented execution", + ) + + # Enforce mode="forbidden" - cannot be called with task metadata + if not task_config.supports_tasks() and task_meta: + raise MCPError( + code=METHOD_NOT_FOUND, + message=f"{entity_label} does not support task-augmented execution", + ) + + # No task metadata - synchronous execution + if not task_meta: + return None + + # fn_key is expected to be set; fall back to component.key for direct calls + fn_key = task_meta.fn_key or component.key + return await submit_to_docket(task_type, fn_key, component, arguments, task_meta) diff --git a/fastmcp_slim/fastmcp/server/tasks/subscriptions.py b/fastmcp_slim/fastmcp/server/tasks/subscriptions.py new file mode 100644 index 000000000..c116bd1bb --- /dev/null +++ b/fastmcp_slim/fastmcp/server/tasks/subscriptions.py @@ -0,0 +1,222 @@ +"""Task subscription helpers for sending MCP notifications (SEP-1686). + +Subscribes to Docket execution state changes and sends notifications/tasks/status +to clients when their tasks change state. + +This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. +""" + +from __future__ import annotations + +from contextlib import suppress +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from docket.execution import ExecutionState +from mcp_types import TaskStatusNotification, TaskStatusNotificationParams + +from fastmcp.server.tasks.config import DEFAULT_TTL_MS +from fastmcp.server.tasks.keys import parse_task_key, task_redis_prefix +from fastmcp.server.tasks.requests import DOCKET_TO_MCP_STATE +from fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution + from mcp.server.session import ServerSession + +logger = get_logger(__name__) + + +async def subscribe_to_task_updates( + task_id: str, + task_key: str, + session: ServerSession, + docket: Docket, + poll_interval_ms: int = 5000, +) -> None: + """Subscribe to Docket execution events and send MCP notifications. + + Per SEP-1686 lines 436-444, servers MAY send notifications/tasks/status + when task state changes. This is an optional optimization that reduces + client polling frequency. + + Args: + task_id: Client-visible task ID (server-generated UUID) + task_key: Internal Docket execution key (includes session, type, component) + session: MCP ServerSession for sending notifications + docket: Docket instance for subscribing to execution events + poll_interval_ms: Poll interval in milliseconds to include in notifications + """ + try: + execution = await docket.get_execution(task_key) + if execution is None: + logger.warning(f"No execution found for task {task_id}") + return + + # Subscribe to state and progress events from Docket + terminal_states = { + ExecutionState.COMPLETED, + ExecutionState.FAILED, + ExecutionState.CANCELLED, + } + async for event in execution.subscribe(): + if event["type"] == "state": + state = ExecutionState(event["state"]) + # Send notifications/tasks/status when state changes + await _send_status_notification( + session=session, + task_id=task_id, + task_key=task_key, + docket=docket, + state=state, + poll_interval_ms=poll_interval_ms, + ) + # Stop subscribing once the task reaches a terminal state + if state in terminal_states: + break + elif event["type"] == "progress": + # Send notification when progress message changes + await _send_progress_notification( + session=session, + task_id=task_id, + task_key=task_key, + docket=docket, + execution=execution, + poll_interval_ms=poll_interval_ms, + ) + + except Exception as e: + logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True) + + +async def _send_status_notification( + session: ServerSession, + task_id: str, + task_key: str, + docket: Docket, + state: ExecutionState, + poll_interval_ms: int = 5000, +) -> None: + """Send notifications/tasks/status to client. + + Per SEP-1686 line 454: notification SHOULD NOT include related-task metadata + (taskId is already in params). + + Args: + session: MCP ServerSession + task_id: Client-visible task ID + task_key: Internal task key (for metadata lookup) + docket: Docket instance + state: Docket execution state (enum) + poll_interval_ms: Poll interval in milliseconds + """ + # Map Docket state to MCP status + state_map = DOCKET_TO_MCP_STATE + mcp_status = state_map.get(state, "failed") + + # Extract task_scope from task_key for Redis lookup + key_parts = parse_task_key(task_key) + task_scope = key_parts["task_scope"] + + created_at_key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:created_at") + async with docket.redis() as redis: + created_at_bytes = await redis.get(created_at_key) + + created_at = ( + created_at_bytes.decode("utf-8") + if created_at_bytes + else datetime.now(timezone.utc).isoformat() + ) + + # Build status message + status_message = None + if state == ExecutionState.COMPLETED: + status_message = "Task completed successfully" + elif state == ExecutionState.FAILED: + status_message = "Task failed" + elif state == ExecutionState.CANCELLED: + status_message = "Task cancelled" + + params_dict = { + "taskId": task_id, + "status": mcp_status, + "createdAt": created_at, + "lastUpdatedAt": datetime.now(timezone.utc).isoformat(), + "ttl": DEFAULT_TTL_MS, + "pollInterval": poll_interval_ms, + } + + if status_message: + params_dict["statusMessage"] = status_message + + # Create notification (no related-task metadata per spec line 454) + notification = TaskStatusNotification( + params=TaskStatusNotificationParams.model_validate(params_dict), + ) + + # Send notification (don't let failures break the subscription) + with suppress(Exception): + await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + +async def _send_progress_notification( + session: ServerSession, + task_id: str, + task_key: str, + docket: Docket, + execution: Execution, + poll_interval_ms: int = 5000, +) -> None: + """Send notifications/tasks/status when progress updates. + + Args: + session: MCP ServerSession + task_id: Client-visible task ID + task_key: Internal task key + docket: Docket instance + execution: Execution object with current progress + poll_interval_ms: Poll interval in milliseconds + """ + # Sync execution to get latest progress + await execution.sync() + + # Only send if there's a progress message + if not execution.progress or not execution.progress.message: + return + + # Map Docket state to MCP status + state_map = DOCKET_TO_MCP_STATE + mcp_status = state_map.get(execution.state, "failed") + + # Extract task_scope from task_key for Redis lookup + key_parts = parse_task_key(task_key) + task_scope = key_parts["task_scope"] + + created_at_key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:created_at") + async with docket.redis() as redis: + created_at_bytes = await redis.get(created_at_key) + + created_at = ( + created_at_bytes.decode("utf-8") + if created_at_bytes + else datetime.now(timezone.utc).isoformat() + ) + + params_dict = { + "taskId": task_id, + "status": mcp_status, + "createdAt": created_at, + "lastUpdatedAt": datetime.now(timezone.utc).isoformat(), + "ttl": DEFAULT_TTL_MS, + "pollInterval": poll_interval_ms, + "statusMessage": execution.progress.message, + } + + # Create and send notification + notification = TaskStatusNotification( + params=TaskStatusNotificationParams.model_validate(params_dict), + ) + + with suppress(Exception): + await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] diff --git a/fastmcp_slim/fastmcp/server/telemetry.py b/fastmcp_slim/fastmcp/server/telemetry.py index 4d073d45b..af5558f0a 100644 --- a/fastmcp_slim/fastmcp/server/telemetry.py +++ b/fastmcp_slim/fastmcp/server/telemetry.py @@ -4,24 +4,11 @@ from collections.abc import Generator from contextlib import contextmanager from contextvars import ContextVar -from opentelemetry import context as otel_context from opentelemetry.context import Context -from opentelemetry.trace import ( - INVALID_SPAN, - Span, - SpanKind, - Status, - StatusCode, - get_current_span, -) +from opentelemetry.trace import Span, SpanKind, Status, StatusCode, get_current_span from fastmcp.exceptions import ToolError as _ToolError -from fastmcp.telemetry import ( - extract_trace_context, - get_tracer, - restore_dropped_attributes, - telemetry_mode, -) +from fastmcp.telemetry import extract_trace_context, get_tracer # Marker attribute set on the SERVER span opened at the FastMCP middleware seam # (see `fastmcp.server.low_level.FastMCPServerMiddleware._seam_span`). The seam @@ -96,32 +83,6 @@ def _get_parent_trace_context() -> Context | None: return None -@contextmanager -def _propagation_only_span() -> Generator[Span, None, None]: - """Attach the incoming `_meta` trace context without creating a span. - - This is what separates `propagation_only` from `off`. Both create no - FastMCP spans, but `off` is fully transparent while `propagation_only` - still has to *parent* whatever the request goes on to do: without the - attach here, the trace context carried in `_meta` would be extracted and - then thrown away, and a span created inside a tool handler — by the user or - by the outer instrumentation layer that owns the MCP hierarchy — would - start a brand new trace instead of continuing the caller's. - - Yields `INVALID_SPAN`, which is non-recording, so callers' `is_recording()` - guards skip attribute and error bookkeeping on it. - """ - parent_context = _get_parent_trace_context() - if parent_context is None: - yield INVALID_SPAN - return - token = otel_context.attach(parent_context) - try: - yield INVALID_SPAN - finally: - otel_context.detach(token) - - def _build_server_span_attrs( method: str, server_name: str, @@ -173,16 +134,7 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]: opening a second one. Exceptions raised anywhere below the seam — including rejections *before* the high-level path (auth, not-found, middleware vetoes) that would otherwise produce no SERVER span at all — are recorded here. - - In `propagation_only` mode no span is opened at all — this is the one place - that has to know the difference, because the seam is where the incoming - `_meta` parent context is applied for the whole request. """ - if telemetry_mode() == "propagation_only": - with _propagation_only_span() as span: - yield span - return - attrs = { SEAM_SPAN_MARKER: True, "mcp.method.name": method, @@ -198,18 +150,16 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]: kind=SpanKind.SERVER, attributes=attrs, ) as span: - # Restore: `attributes=attrs` above is what makes on_start hooks and + # Reapply: `attributes=attrs` above is what makes on_start hooks and # the sampler see these values at creation time (the whole point of # this helper). But OTel's Tracer.start_span builds the span from # `sampling_result.attributes`, not the `attributes` kwarg directly — # a custom Sampler whose SamplingResult.attributes defaults to None - # silently drops everything we passed. This only fires when the span - # ends up with no attributes at all, so any sampler that supplied - # attributes of its own — forwarding ours, redacting or replacing - # some, or substituting entirely its own — is left untouched, as is - # an SDK attribute limit that evicted some. + # silently drops everything we passed. Reapplying here (additive, + # can't clobber anything a sampler legitimately added) guarantees + # FastMCP's attributes survive regardless of sampler behavior. if span.is_recording(): - restore_dropped_attributes(span, attrs) + span.set_attributes(attrs) token = _active_seam_span.set(span) try: yield span @@ -242,17 +192,7 @@ def server_span( new SERVER span as before. Automatically records any exception on the span and sets error status. - - In `propagation_only` mode no span is opened or enriched. The seam has - normally already attached the incoming parent context for this request; - doing it again here is a no-op, and covers the in-process callers that - bypass the dispatcher and so never reach the seam at all. """ - if telemetry_mode() == "propagation_only": - with _propagation_only_span() as span: - yield span - return - attrs = _build_server_span_attrs( method, server_name, @@ -288,12 +228,11 @@ def server_span( kind=SpanKind.SERVER, attributes=attrs, ) as span: - # Restore for the same reason as `seam_span`: OTel builds the span + # Reapply for the same reason as `seam_span`: OTel builds the span # from `sampling_result.attributes`, which a custom Sampler may not - # forward even though it was handed `attributes=attrs` above. Only - # fires when the span ends up with no attributes at all. + # forward even though it was handed `attributes=attrs` above. if span.is_recording(): - restore_dropped_attributes(span, attrs) + span.set_attributes(attrs) try: yield span except Exception as e: @@ -322,12 +261,11 @@ def delegate_span( tracer = get_tracer() with tracer.start_as_current_span(f"delegate {name}", attributes=attrs) as span: - # Restore for the same reason as `seam_span`: OTel builds the span + # Reapply for the same reason as `seam_span`: OTel builds the span # from `sampling_result.attributes`, which a custom Sampler may not - # forward even though it was handed `attributes=attrs` above. Only - # fires when the span ends up with no attributes at all. + # forward even though it was handed `attributes=attrs` above. if span.is_recording(): - restore_dropped_attributes(span, attrs) + span.set_attributes(attrs) try: yield span except Exception as e: diff --git a/fastmcp_slim/fastmcp/server/transforms/catalog.py b/fastmcp_slim/fastmcp/server/transforms/catalog.py index e1ea30486..936fcd9b3 100644 --- a/fastmcp_slim/fastmcp/server/transforms/catalog.py +++ b/fastmcp_slim/fastmcp/server/transforms/catalog.py @@ -49,7 +49,6 @@ from collections.abc import Sequence from contextvars import ContextVar from typing import TYPE_CHECKING -from fastmcp.apps.config import is_model_visible from fastmcp.server.transforms import Transform from fastmcp.utilities.versions import dedupe_with_versions @@ -178,16 +177,6 @@ class CatalogTransform(Transform): of each tool is returned — matching what protocol handlers expose on the wire. - Tools the model may not see are excluded. A catalog is read by the - model as tool output rather than advertised as ``tools/list``, so the - host filtering the spec relies on never applies to it — this is the - only place the declaration can be enforced. - - Visibility is checked after deduplication, on the version a bare name - actually reaches. Checking first would let a model-visible older - version advertise a name whose highest version is app-only, and the - call would run the version nobody was shown. - Args: ctx: The current request context. run_middleware: Whether to run middleware on the inner call. @@ -199,8 +188,7 @@ class CatalogTransform(Transform): tools = await ctx.fastmcp.list_tools(run_middleware=run_middleware) finally: self._bypass.reset(token) - selected = dedupe_with_versions(tools, lambda t: t.name) - return [tool for tool in selected if is_model_visible(tool)] + return dedupe_with_versions(tools, lambda t: t.name) async def get_resource_catalog( self, ctx: Context, *, run_middleware: bool = True diff --git a/fastmcp_slim/fastmcp/server/transforms/search/base.py b/fastmcp_slim/fastmcp/server/transforms/search/base.py index cdb47680b..cfeac6909 100644 --- a/fastmcp_slim/fastmcp/server/transforms/search/base.py +++ b/fastmcp_slim/fastmcp/server/transforms/search/base.py @@ -31,7 +31,6 @@ from abc import abstractmethod from collections.abc import Awaitable, Callable, Sequence from typing import Annotated, Any -from fastmcp.exceptions import NotFoundError from fastmcp.server.context import Context from fastmcp.server.transforms import GetToolNext from fastmcp.server.transforms.catalog import CatalogTransform @@ -241,13 +240,6 @@ class BaseSearchTransform(CatalogTransform): raise ValueError( f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy" ) - # The name comes from the model, so this proxy is a second way - # into the server that no host mediates. It may reach only what - # the model was allowed to discover. - if not any( - tool.name == name for tool in await transform.get_tool_catalog(ctx) - ): - raise NotFoundError(f"Unknown tool: {name!r}") return await ctx.fastmcp.call_tool(name, arguments) return Tool.from_function(fn=call_tool, name=self._call_tool_name) diff --git a/fastmcp_slim/fastmcp/server/transforms/visibility.py b/fastmcp_slim/fastmcp/server/transforms/visibility.py index 0e6a7fe5a..d294d49ef 100644 --- a/fastmcp_slim/fastmcp/server/transforms/visibility.py +++ b/fastmcp_slim/fastmcp/server/transforms/visibility.py @@ -7,7 +7,6 @@ Final filtering happens at the Provider level. from __future__ import annotations -import warnings from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Literal, TypeVar @@ -82,19 +81,6 @@ class Visibility(Transform): components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ - if keys: - malformed = sorted(key for key in keys if "@" not in key) - if malformed: - warnings.warn( - f"Component keys are missing the '@' version delimiter and will " - f"match nothing: {malformed}. A key always ends in '@' for an " - f"unversioned component (e.g. 'tool:my_tool@') or '@<version>' " - f"for a versioned one. Read the value from `component.key`, or " - f"filter by `names` instead.", - UserWarning, - stacklevel=3, - ) - self._enabled = enabled self.names = names self.keys = keys diff --git a/fastmcp_slim/fastmcp/settings.py b/fastmcp_slim/fastmcp/settings.py index d442d7218..a2a37d24a 100644 --- a/fastmcp_slim/fastmcp/settings.py +++ b/fastmcp_slim/fastmcp/settings.py @@ -2,6 +2,7 @@ from __future__ import annotations as _annotations import inspect import os +from datetime import timedelta from pathlib import Path from typing import Annotated, Any, Literal @@ -20,8 +21,6 @@ ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] -TELEMETRY_MODE = Literal["native", "propagation_only", "off"] - MCP_LOG_LEVEL = Literal[ "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" ] @@ -31,6 +30,109 @@ DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] TEN_MB_IN_BYTES = 1024 * 1024 * 10 +class DocketSettings(BaseSettings): + """Docket worker configuration.""" + + model_config = SettingsConfigDict( + env_prefix="FASTMCP_DOCKET_", + extra="ignore", + ) + + name: Annotated[ + str, + Field( + description=inspect.cleandoc( + """ + Name for the Docket queue. All servers/workers sharing the same name + and backend URL will share a task queue. + """ + ), + ), + ] = "fastmcp" + + url: Annotated[ + str, + Field( + description=inspect.cleandoc( + """ + URL for the Docket backend. Supports: + - memory:// - In-memory backend (single process only) + - redis://host:port/db - Redis/Valkey backend (distributed, multi-process) + + Example: redis://localhost:6379/0 + + Default is memory:// for single-process scenarios. Use Redis or Valkey + when coordinating tasks across multiple processes (e.g., additional + workers via the fastmcp tasks CLI). + """ + ), + ), + ] = "memory://" + + worker_name: Annotated[ + str | None, + Field( + description=inspect.cleandoc( + """ + Name for the Docket worker. If None, Docket will auto-generate + a unique worker name. + """ + ), + ), + ] = None + + concurrency: Annotated[ + int, + Field( + description=inspect.cleandoc( + """ + Maximum number of tasks the worker can process concurrently. + """ + ), + ), + ] = 10 + + redelivery_timeout: Annotated[ + timedelta, + Field( + description=inspect.cleandoc( + """ + Task redelivery timeout. If a worker doesn't complete + a task within this time, the task will be redelivered to another + worker. + """ + ), + ), + ] = timedelta(seconds=300) + + reconnection_delay: Annotated[ + timedelta, + Field( + description=inspect.cleandoc( + """ + Delay between reconnection attempts when the worker + loses connection to the Docket backend. + """ + ), + ), + ] = timedelta(seconds=5) + + minimum_check_interval: Annotated[ + timedelta, + Field( + description=inspect.cleandoc( + """ + How frequently the worker polls for new tasks. Lower + values reduce latency for task pickup at the cost of + more CPU usage. The default of 50ms is a good balance; + increase for high-volume production deployments where + tasks are long-running. + """ + ), + ), + ] = timedelta(milliseconds=50) + + class Settings(BaseSettings): """FastMCP settings.""" @@ -83,6 +185,8 @@ class Settings(BaseSettings): return v.upper() return v + docket: DocketSettings = DocketSettings() + enable_rich_logging: Annotated[ bool, Field( @@ -106,6 +210,24 @@ class Settings(BaseSettings): ), ] = True + enable_telemetry: Annotated[ + bool, + Field( + description=inspect.cleandoc( + """ + Whether FastMCP's native OpenTelemetry instrumentation is active. + Enabled by default: FastMCP uses only the OpenTelemetry API, so + span creation is a no-op with negligible overhead unless an + OpenTelemetry SDK and exporter are configured. Set to False to + turn instrumentation off entirely, in which case FastMCP's span + helpers become a transparent pass-through: no FastMCP spans are + created even when an SDK is configured, and the surrounding OTel + trace context is left untouched. + """ + ) + ), + ] = True + deprecation_warnings: Annotated[ bool, Field( @@ -151,31 +273,6 @@ class Settings(BaseSettings): ), ] = True - telemetry_mode: Annotated[ - TELEMETRY_MODE, - Field( - description=inspect.cleandoc( - """ - Controls FastMCP's native OpenTelemetry instrumentation. - - - `native` (default): FastMCP creates MCP spans and propagates - trace context through request `_meta`. FastMCP uses only the - OpenTelemetry API, so span creation is a no-op with negligible - overhead unless an SDK and exporter are configured. - - `propagation_only`: FastMCP still injects and extracts trace - context, and still parents downstream spans from the incoming - `_meta` context, but creates none of its own MCP spans. Use - this when another instrumentation layer owns the MCP span - hierarchy and FastMCP's spans would duplicate it. - - `off`: FastMCP's span helpers become a transparent - pass-through. No spans are created even when an SDK is - configured, and the surrounding OTel context is left - untouched — no trace context is extracted or attached. - """ - ), - ), - ] = "native" - client_init_timeout: Annotated[ float | None, Field( @@ -247,26 +344,6 @@ class Settings(BaseSettings): ), ] = False - ssrf_trust_proxy: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - Trust an outbound HTTP proxy for SSRF-protected fetches (OAuth client - metadata and JWKS). When False (default), FastMCP resolves the target - hostname itself and refuses to connect if it maps to a private, - loopback, link-local, or otherwise reserved IP. When True, FastMCP - routes auth metadata and JWKS fetches through the configured - HTTPS_PROXY/ALL_PROXY and does not honor NO_PROXY; if no proxy is - configured the fetch is refused (raising SSRFError) rather than sent - direct with the blocklist disabled. Only enable this when a trusted - corporate proxy is the mandated egress path: it shifts SSRF trust to - that proxy. Scheme (HTTPS-only) and hostname checks still apply. - """ - ), - ), - ] = False - server_dependencies: list[str] = Field( default_factory=list, description="List of dependencies to install in the server environment", diff --git a/fastmcp_slim/fastmcp/telemetry.py b/fastmcp_slim/fastmcp/telemetry.py index cf379818b..c1bb66dfd 100644 --- a/fastmcp_slim/fastmcp/telemetry.py +++ b/fastmcp_slim/fastmcp/telemetry.py @@ -21,9 +21,9 @@ Example usage with SDK: ``` """ -from collections.abc import Iterator, Mapping +from collections.abc import Iterator from contextlib import contextmanager -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import Any from opentelemetry import context as otel_context from opentelemetry import propagate, trace @@ -40,9 +40,6 @@ from opentelemetry.trace import ( from opentelemetry.trace import get_tracer as otel_get_tracer from opentelemetry.util import types as otel_types -if TYPE_CHECKING: - from fastmcp.settings import TELEMETRY_MODE as TelemetryMode - INSTRUMENTATION_NAME = "fastmcp" TRACE_PARENT_KEY = "traceparent" @@ -80,71 +77,28 @@ class _DisabledTracer(NoOpTracer): _DISABLED_TRACER = _DisabledTracer() -_SUPPRESS_KEY = otel_context.create_key("fastmcp_suppress_telemetry") - - -def telemetry_mode() -> "TelemetryMode": - """Resolve the effective telemetry mode for the current context. - - This is `fastmcp.settings.telemetry_mode`, except that an active - `suppress_fastmcp_telemetry()` block downgrades `native` to - `propagation_only`. Suppression never upgrades or overrides `off`: `off` - means FastMCP touches nothing, and a narrower request to skip FastMCP's - spans cannot re-enable the context propagation `off` deliberately omits. - """ - import fastmcp - - mode: TelemetryMode = fastmcp.settings.telemetry_mode - if mode == "native" and otel_context.get_value(_SUPPRESS_KEY): - return "propagation_only" - return mode - - -def native_spans_enabled() -> bool: - """Whether FastMCP should create its own spans right now.""" - return telemetry_mode() == "native" - - -@contextmanager -def suppress_fastmcp_telemetry() -> Iterator[None]: - """Suppress FastMCP's own spans without disabling trace propagation. - - Scoped equivalent of `telemetry_mode="propagation_only"`, for callers that - embed FastMCP inside their own instrumented stack and want to own the MCP - span hierarchy for a specific block. Narrower than OpenTelemetry's global - instrumentation suppression: only FastMCP's spans are skipped, so nested - instrumentation (HTTP clients, databases) keeps emitting, and trace context - still flows through `_meta` so those spans are parented correctly. - - Has no effect when `telemetry_mode` is already `off`. - """ - token = otel_context.attach(otel_context.set_value(_SUPPRESS_KEY, True)) - try: - yield - finally: - otel_context.detach(token) - def get_tracer(version: str | None = None) -> Tracer: """Get the FastMCP tracer for creating spans. Instrumentation is on by default. FastMCP uses only the OpenTelemetry API, so span creation is a no-op with negligible overhead unless an OpenTelemetry - SDK and exporter are configured. When `fastmcp.settings.telemetry_mode` is - `propagation_only` or `off` — or the caller is inside a - `suppress_fastmcp_telemetry()` block — this returns a pass-through tracer - that creates no spans and leaves the current OTel context untouched even - when an SDK is configured. + SDK and exporter are configured. Set `fastmcp.settings.enable_telemetry` to + False (env `FASTMCP_ENABLE_TELEMETRY=false`) to turn instrumentation off + entirely, in which case this returns a pass-through tracer that leaves the + current OTel context untouched even when an SDK is configured. Args: version: Optional version string for the instrumentation Returns: - A tracer instance. Returns a non-attaching pass-through tracer when - FastMCP's own spans are disabled; span creation is otherwise a no-op - unless an SDK is configured. + A tracer instance. Returns a non-attaching pass-through tracer if + telemetry is disabled; span creation is otherwise a no-op unless an SDK + is configured. """ - if not native_spans_enabled(): + import fastmcp + + if not fastmcp.settings.enable_telemetry: return _DISABLED_TRACER return otel_get_tracer(INSTRUMENTATION_NAME, version) @@ -161,11 +115,6 @@ def inject_trace_context( A new dict containing the original meta (if any) plus trace context keys, or None if no trace context to inject and meta was None """ - # `off` means FastMCP touches nothing, outbound propagation included. - # `propagation_only` still injects — carrying context is the whole point. - if telemetry_mode() == "off": - return meta - carrier: dict[str, str] = {} propagate.inject(carrier) @@ -186,80 +135,6 @@ def record_span_error(span: Span, exception: BaseException) -> None: span.set_status(Status(StatusCode.ERROR)) -@runtime_checkable -class _AttributeReadableSpan(Protocol): - """Structural type for spans that expose their current attribute state. - - The `opentelemetry-api` `Span` ABC has no way to read attributes back — - only SDK span implementations (e.g. `opentelemetry.sdk.trace.ReadableSpan`) - expose `.attributes` and `.dropped_attributes`. FastMCP only depends on - `opentelemetry-api`, so this module can't import the SDK class to - `isinstance`-check against it. A runtime-checkable `Protocol` gets the - same structural narrowing without that import: spans that don't expose - this state (e.g. `NonRecordingSpan`) simply fail the check. - """ - - @property - def attributes(self) -> Mapping[str, otel_types.AttributeValue] | None: ... - - @property - def dropped_attributes(self) -> int: ... - - -def restore_dropped_attributes( - span: Span, attrs: Mapping[str, otel_types.AttributeValue] -) -> None: - """Restore FastMCP attributes a non-forwarding sampler dropped entirely. - - `Tracer.start_span` builds the span from `SamplingResult.attributes`, not - the `attributes=` kwarg it was given for creation — a custom `Sampler` - whose `SamplingResult.attributes` defaults to `None` silently discards - every attribute FastMCP passed at creation time. Call this immediately - after span creation to recover from that case. - - The restore only fires when the span has *no* attributes at all AND the - SDK hasn't evicted anything (`dropped_attributes == 0`): - - - A bare, non-forwarding sampler (the regression this exists to fix) - leaves the span with an empty attribute mapping, so everything is - restored. - - A sampler that supplied any attributes of its own — whether by - forwarding ours untouched, redacting or replacing some of our values, - or substituting its own attributes entirely (e.g. to strip component - names or resource URIs for privacy or cardinality control) — leaves - the span non-empty, so it is left alone entirely. This is what makes - the gate precise: a sampler that deliberately supplies only its own - attributes must not have them clobbered by a restore that assumes - "no FastMCP keys" means "sampler forwarding failed." - - A sampler that forwards most of our attributes but deliberately drops - one is still non-empty, so it's covered by the same "leave alone" - branch — a dropped key here is indistinguishable from the SDK's - bounded attribute map evicting it, and reinserting it would just push - the map's bound and evict a *different* retained key, churning which - attributes survive without changing how many are lost. No attempt is - made to restore individual missing keys; the gate is all-or-nothing. - - A low `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT` that evicts every attribute a - forwarding sampler passed through is indistinguishable, from the - span's attribute state alone, from a bare non-forwarding sampler — - both leave an empty mapping. `dropped_attributes == 0` is what tells - them apart: eviction always increments it, so that case is correctly - excluded from the restore and the SDK's bounded map is left as - computed. - - Callers are expected to guard this with `if span.is_recording():`; it - does no work worth skipping for non-recording spans, but the check is - kept at call sites so it reads alongside the sibling `is_recording()` - guards already in those functions. - """ - existing: Mapping[str, otel_types.AttributeValue] = {} - dropped = 0 - if isinstance(span, _AttributeReadableSpan): - existing = span.attributes or {} - dropped = span.dropped_attributes - if not existing and dropped == 0: - span.set_attributes(attrs) - - def extract_trace_context(meta: dict[str, Any] | None) -> Context: """Extract trace context from an MCP request meta dict. @@ -273,10 +148,6 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context: An OpenTelemetry Context with the extracted trace context, or the current context if no trace context found or already in a trace """ - # `off` means FastMCP touches nothing, including the surrounding context. - if telemetry_mode() == "off": - return otel_context.get_current() - # Don't override existing trace context (e.g., from HTTP propagation) current_span = trace.get_current_span() if current_span.get_span_context().is_valid: @@ -292,12 +163,7 @@ def extract_trace_context(meta: dict[str, Any] | None) -> Context: carrier["tracestate"] = str(meta[TRACE_STATE_KEY]) if carrier: - # Extract *onto the current context* rather than a fresh root, so the - # incoming parent is added without discarding context values the - # caller already established — active baggage, and FastMCP's own - # suppression marker, which would otherwise be dropped the moment the - # extracted context is attached. - return propagate.extract(carrier, context=otel_context.get_current()) + return propagate.extract(carrier) return otel_context.get_current() @@ -308,9 +174,5 @@ __all__ = [ "extract_trace_context", "get_tracer", "inject_trace_context", - "native_spans_enabled", "record_span_error", - "restore_dropped_attributes", - "suppress_fastmcp_telemetry", - "telemetry_mode", ] diff --git a/fastmcp_slim/fastmcp/tools/__init__.py b/fastmcp_slim/fastmcp/tools/__init__.py index d3f7303fc..64360b2aa 100644 --- a/fastmcp_slim/fastmcp/tools/__init__.py +++ b/fastmcp_slim/fastmcp/tools/__init__.py @@ -1,10 +1,17 @@ +import sys + from .function_tool import FunctionTool, tool -from .base import InputRequiredToolResult, Tool, ToolResult +from .base import Tool, ToolResult from .tool_transform import forward, forward_raw +# Backward compat: tool.py was renamed to base.py to stop Pyright from resolving +# `from fastmcp.tools import tool` as the submodule instead of the decorator function. +# This shim keeps `from fastmcp.tools.tool import Tool` working at runtime. +# Safe to remove once we're confident no external code imports from the old path. +sys.modules[f"{__name__}.tool"] = sys.modules[f"{__name__}.base"] + __all__ = [ "FunctionTool", - "InputRequiredToolResult", "Tool", "ToolResult", "forward", diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index 9e11ad5c9..011cacd27 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -1,12 +1,12 @@ from __future__ import annotations -import inspect from collections.abc import Callable from typing import ( TYPE_CHECKING, Annotated, Any, ClassVar, + overload, ) import mcp_types @@ -21,34 +21,33 @@ from mcp_types import ( ToolExecution, ) from mcp_types import Tool as MCPTool -from pydantic import ( - BaseModel, - Field, - PrivateAttr, - PydanticSchemaGenerationError, - model_validator, -) +from pydantic import BaseModel, Field, model_validator from pydantic.json_schema import SkipJsonSchema from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.prefab import ( - is_prefab_app, - is_prefab_component, - prefab_app_from_component, -) -from fastmcp.utilities.tasks import TaskConfig +from fastmcp.utilities.tasks import TaskConfig, TaskMeta from fastmcp.utilities.types import ( Audio, File, Image, NotSet, NotSetT, - get_cached_typeadapter, ) +try: + from prefab_ui.app import PrefabApp as _PrefabApp + from prefab_ui.components.base import Component as _PrefabComponent + + _HAS_PREFAB = True +except ImportError: + _HAS_PREFAB = False + if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution + from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import ArgTransform, TransformedTool @@ -56,45 +55,38 @@ if TYPE_CHECKING: logger = get_logger(__name__) -_JSONABLE_ADAPTER = get_cached_typeadapter(Any) +def resolve_serialize_by_alias(value: Any) -> bool: + """Resolve the effective ``by_alias`` setting for serializing *value*. -def _default_title(name: str) -> str: - """Derive a display title from a tool name. - - The MCP spec says clients should fall back to `name` for display when - `title` is absent, but some clients (e.g. ChatGPT) instead drop the tool - entirely. Always emitting a title avoids depending on that fallback. + Pydantic's low-level serialization helpers (``to_json``, + ``to_jsonable_python``) default ``by_alias`` to ``True``, which silently + ignores a model's ``serialize_by_alias`` config. When *value* is a Pydantic + model we consult that config instead, falling back to ``True`` to preserve + FastMCP's longstanding default of emitting aliases when no preference is + declared. """ - return name.replace("_", " ").replace("-", " ").title() + if isinstance(value, type): + model = value if issubclass(value, BaseModel) else None + elif isinstance(value, BaseModel): + model = type(value) + else: + model = None + + if model is None: + return True + + configured = model.model_config.get("serialize_by_alias") + return True if configured is None else configured def default_serializer(data: Any) -> str: - return _JSONABLE_ADAPTER.dump_json(data, fallback=str).decode() - - -def _serialize_to_jsonable(data: Any, annotation: Any = Any) -> Any: - """Serialize through Pydantic, falling back for unsupported annotations.""" - if ( - annotation is inspect.Signature.empty - or annotation is None - or annotation is Any - or annotation is ... - or isinstance(annotation, str) - ): - adapter = _JSONABLE_ADAPTER - else: - try: - return get_cached_typeadapter(annotation).dump_python(data, mode="json") - except PydanticSchemaGenerationError: - adapter = _JSONABLE_ADAPTER - - return adapter.dump_python(data, mode="json") + return pydantic_core.to_json( + data, fallback=str, by_alias=resolve_serialize_by_alias(data) + ).decode() class ToolResult(BaseModel): - _raw_mcp_result: CallToolResult | None = PrivateAttr(default=None) - content: list[ContentBlock] = Field( description="List of content blocks for the tool result" ) @@ -128,15 +120,19 @@ class ToolResult(BaseModel): if structured_content is not None: # Convert Prefab types to their wire-format envelope before # generic serialization, so the renderer gets the right shape. - if is_prefab_app(structured_content): - structured_content = _prefab_to_json(structured_content) - elif is_prefab_component(structured_content): - structured_content = _prefab_to_json( - prefab_app_from_component(structured_content) - ) + if _HAS_PREFAB: + if isinstance(structured_content, _PrefabApp): + structured_content = _prefab_to_json(structured_content) + elif isinstance(structured_content, _PrefabComponent): + structured_content = _prefab_to_json( + _PrefabApp(view=structured_content) + ) try: - structured_content = _serialize_to_jsonable(structured_content) + structured_content = pydantic_core.to_jsonable_python( + value=structured_content, + by_alias=resolve_serialize_by_alias(structured_content), + ) except pydantic_core.PydanticSerializationError as e: logger.error( f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}" @@ -156,26 +152,11 @@ class ToolResult(BaseModel): is_error=is_error, ) - @classmethod - def from_mcp_result(cls, result: CallToolResult) -> ToolResult: - """Wrap a protocol result while preserving its exact wire representation.""" - tool_result = cls( - content=result.content, - structured_content=result.structured_content, - meta=result.meta, - is_error=result.is_error, - ) - tool_result._raw_mcp_result = result - return tool_result - def to_mcp_result( self, ) -> ( list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult ): - if self._raw_mcp_result is not None: - return self._raw_mcp_result - # An error result must round-trip through CallToolResult so isError # reaches the client; the plain content/tuple returns can't carry it. if self.meta is not None or self.is_error: @@ -190,50 +171,11 @@ class ToolResult(BaseModel): return self.content, self.structured_content -class InputRequiredToolResult(ToolResult): - """The full result of a single multi-round-trip leg (SEP-2322). - - The protocol is stateless: each MRTR leg is a complete request→response - cycle. When a guard tool returns an `InputRequiredResult` from its body to - ask the client for input, that ask is the *legitimate result* of this tool - call — not a pause, not an error, not a third control-flow outcome. FastMCP - wraps it in this `ToolResult` subclass so it flows through the middleware - chain as an ordinary return value: `call_next(...)` returns it, default - middleware completes normally on the leg, and middleware authors can - identify an ask with a simple `isinstance(result, InputRequiredToolResult)` - check. - - Invariant: the wrapped `InputRequiredResult` is never serialized as tool - content. `content` is always empty; the wire handler (`_on_call_tool`) - reads `.input_required` and returns it to the runner as the - `input_required` result. Do not read `.content` / `.structured_content` on - this subclass — they carry nothing. - """ - - input_required: mcp_types.InputRequiredResult = Field( - description="The client-input request this leg resolved to (SEP-2322)" - ) - - def __init__(self, input_required: mcp_types.InputRequiredResult) -> None: - # Bypass ToolResult's content-conversion __init__: an input-required - # leg carries no tool content (see the invariant above), and - # `input_required` is a required field ToolResult.__init__ can't set. - BaseModel.__init__( - self, - content=[], - structured_content=None, - meta=None, - is_error=False, - input_required=input_required, - ) - - class Tool(FastMCPComponent): """Internal tool registration info.""" KEY_PREFIX: ClassVar[str] = "tool" - return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None parameters: Annotated[ dict[str, Any], Field(description="JSON schema for tool parameters") ] @@ -270,28 +212,21 @@ class Tool(FastMCPComponent): **overrides: Any, ) -> MCPTool: """Convert the FastMCP tool to an MCP tool.""" - # Title precedence follows the effective (post-override) values, so a - # caller renaming or re-annotating a tool doesn't get a stale title. - name = overrides.get("name", self.name) - annotations = overrides.get("annotations", self.annotations) - if isinstance(annotations, dict): - annotations = ToolAnnotations(**annotations) + title = None if self.title: title = self.title - elif annotations and annotations.title: - title = annotations.title - else: - title = _default_title(name) + elif self.annotations and self.annotations.title: + title = self.annotations.title mcp_tool = MCPTool( - name=name, + name=overrides.get("name", self.name), title=overrides.get("title", title), description=overrides.get("description", self.description), input_schema=overrides.get("inputSchema", self.parameters), output_schema=overrides.get("outputSchema", self.output_schema), icons=overrides.get("icons", self.icons), - annotations=annotations, + annotations=overrides.get("annotations", self.annotations), execution=overrides.get("execution", self.execution), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() @@ -355,12 +290,6 @@ class Tool(FastMCPComponent): `run()` can EITHER return a list of ContentBlocks, or a tuple of (list of ContentBlocks, dict of structured output). - - A tool that requests client input (SEP-2322 multi-round-trip) does so by - returning an `InputRequiredResult` from its body; the run machinery wraps - that in an `InputRequiredToolResult` — a `ToolResult` subclass — so it - stays inside the declared `ToolResult` result type and flows through the - middleware chain as an ordinary result (see `FunctionTool.run`). """ raise NotImplementedError("Subclasses must implement run()") @@ -373,19 +302,17 @@ class Tool(FastMCPComponent): if isinstance(raw_value, ToolResult): return raw_value - if isinstance(raw_value, CallToolResult): - return ToolResult.from_mcp_result(raw_value) - - if is_prefab_app(raw_value): - return _prefab_to_tool_result( - raw_value, - fastmcp_app_name=_get_fastmcp_app_name(self), - ) - if is_prefab_component(raw_value): - return _prefab_to_tool_result( - prefab_app_from_component(raw_value), - fastmcp_app_name=_get_fastmcp_app_name(self), - ) + if _HAS_PREFAB: + if isinstance(raw_value, _PrefabApp): + return _prefab_to_tool_result( + raw_value, + fastmcp_app_name=_get_fastmcp_app_name(self), + ) + if isinstance(raw_value, _PrefabComponent): + return _prefab_to_tool_result( + _PrefabApp(view=raw_value), + fastmcp_app_name=_get_fastmcp_app_name(self), + ) content = _convert_to_content(raw_value) @@ -393,29 +320,24 @@ class Tool(FastMCPComponent): if isinstance(raw_value, bytes): return ToolResult(content=content) - is_content_result = isinstance( - raw_value, ContentBlock | Audio | Image | File - ) or ( - isinstance(raw_value, list | tuple) - and any( - isinstance(item, ContentBlock | Audio | Image | File) - for item in raw_value - ) - ) - # Skip structured content for ContentBlock types only if no output_schema # (if output_schema exists, MCP SDK requires structured_content) - if self.output_schema is None and is_content_result: + if self.output_schema is None and ( + isinstance(raw_value, ContentBlock | Audio | Image | File) + or ( + isinstance(raw_value, list | tuple) + and any(isinstance(item, ContentBlock) for item in raw_value) + ) + ): return ToolResult(content=content) try: - structured = _serialize_to_jsonable(raw_value, self.return_type) + structured = pydantic_core.to_jsonable_python( + raw_value, by_alias=resolve_serialize_by_alias(raw_value) + ) except (pydantic_core.PydanticSerializationError, UnicodeDecodeError): return ToolResult(content=content) - if not is_content_result: - content = _convert_to_content(structured) - if self.output_schema is None: # No schema - only use structured_content for dicts if isinstance(structured, dict): @@ -430,15 +352,87 @@ class Tool(FastMCPComponent): meta={"fastmcp": {"wrap_result": True}} if wrap_result else None, ) - async def _run(self, arguments: dict[str, Any]) -> ToolResult: - """Server entry point for tool execution. + @overload + async def _run( + self, + arguments: dict[str, Any], + task_meta: None = None, + ) -> ToolResult: ... - The server calls this method instead of ``run()`` directly so that - subclasses can customize dispatch. For example, ``FastMCPProviderTool`` - overrides this to delegate to child-server middleware. + @overload + async def _run( + self, + arguments: dict[str, Any], + task_meta: TaskMeta, + ) -> mcp_types.CreateTaskResult: ... + + async def _run( + self, + arguments: dict[str, Any], + task_meta: TaskMeta | None = None, + ) -> ToolResult | mcp_types.CreateTaskResult: + """Server entry point that handles task routing. + + This allows ANY Tool subclass to support background execution by setting + task_config.mode to "supported" or "required". The server calls this + method instead of run() directly. + + Args: + arguments: Tool arguments + task_meta: If provided, execute as background task and return + CreateTaskResult. If None (default), execute synchronously and + return ToolResult. + + Returns: + ToolResult when task_meta is None. + CreateTaskResult when task_meta is provided. + + Subclasses can override this to customize task routing behavior. + For example, FastMCPProviderTool overrides to delegate to child + middleware without submitting to Docket. """ + from fastmcp.server.tasks.routing import check_background_task + + task_result = await check_background_task( + component=self, + task_type="tool", + arguments=arguments, + task_meta=task_meta, + ) + if task_result: + return task_result + return await self.run(arguments) + def register_with_docket(self, docket: Docket) -> None: + """Register this tool with docket for background execution.""" + if not self.task_config.supports_tasks(): + return + docket.register(self.run, names=[self.key]) + + async def add_to_docket( # type: ignore[override] + self, + docket: Docket, + arguments: dict[str, Any], + *, + fn_key: str | None = None, + task_key: str | None = None, + **kwargs: Any, + ) -> Execution: + """Schedule this tool for background execution via docket. + + Args: + docket: The Docket instance + arguments: Tool arguments + fn_key: Function lookup key in Docket registry (defaults to self.key) + task_key: Redis storage key for the result + **kwargs: Additional kwargs passed to docket.add() + """ + lookup_key = fn_key or self.key + if task_key: + kwargs["key"] = task_key + return await docket.add(lookup_key, **kwargs)(arguments) + @classmethod def from_tool( cls, @@ -536,17 +530,15 @@ def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]: - """Serialize a PrefabApp, addressing its peer-tool references by identity. + """Call PrefabApp.to_json() with the hash-based resolver. - The resolver writes each reference as ``<hash>_<local_name>``, and the - identity behind it is recorded in the payload's meta so that servers - can re-address the reference on the way out without losing track of - what it points at. + The resolver prefixes peer-tool references with a deterministic hash + derived from the app name + tool name. The dispatcher recognizes that + format and routes calls via ``get_tool_by_hash`` which walks the + provider tree recursively — same pattern as the old ``get_app_tool``. """ - from fastmcp.server.providers.prefab_payload import annotate_payload_identities - data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name)) - return annotate_payload_identities(data) + return data def _get_fastmcp_app_name(tool: Tool) -> str | None: @@ -598,4 +590,4 @@ def _convert_to_content( return [TextContent(type="text", text=default_serializer(result))] -__all__ = ["InputRequiredToolResult", "Tool", "ToolResult"] +__all__ = ["Tool", "ToolResult"] diff --git a/fastmcp_slim/fastmcp/tools/function_parsing.py b/fastmcp_slim/fastmcp/tools/function_parsing.py index 72d594794..79967f9a2 100644 --- a/fastmcp_slim/fastmcp/tools/function_parsing.py +++ b/fastmcp_slim/fastmcp/tools/function_parsing.py @@ -10,17 +10,13 @@ from dataclasses import dataclass from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints import mcp_types -from pydantic import PydanticSchemaGenerationError -from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue -from pydantic_core import core_schema -from typing_extensions import TypeAliasType +from pydantic import BaseModel, PydanticSchemaGenerationError from typing_extensions import TypeVar as TypeVarExt -from fastmcp.tools.base import ToolResult +from fastmcp.tools.base import ToolResult, resolve_serialize_by_alias from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.prefab import is_prefab_type from fastmcp.utilities.types import ( Audio, File, @@ -30,6 +26,14 @@ from fastmcp.utilities.types import ( replace_type, ) +try: + from prefab_ui.app import PrefabApp as _PrefabApp + from prefab_ui.components.base import Component as _PrefabComponent + + _PREFAB_TYPES: tuple[type, ...] = (_PrefabApp, _PrefabComponent) +except ImportError: + _PREFAB_TYPES = () + def _contains_bytes_type(tp: Any) -> bool: """Check if *tp* is or contains bytes, recursing through unions and Annotated.""" @@ -43,7 +47,7 @@ def _contains_bytes_type(tp: Any) -> bool: def _contains_prefab_type(tp: Any) -> bool: """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" - if is_prefab_type(tp): + if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES): return True origin = get_origin(tp) if origin is Union or origin is types.UnionType or origin is Annotated: @@ -51,127 +55,51 @@ def _contains_prefab_type(tp: Any) -> bool: return False -def _unwrap_type_alias(tp: Any) -> Any: - """Resolve a PEP 695 ``type X = ...`` alias to its underlying value. - - ``get_origin()`` returns ``None`` for a ``TypeAliasType``, so an alias that - factors out a guard union (``type Result = str | InputRequiredResult``) — or - a lone aliased arm — would otherwise slip past union detection. Resolving to - ``__value__`` (repeatedly, for chained aliases) restores the concrete type. - """ - while isinstance(tp, TypeAliasType): - tp = tp.__value__ - return tp - - -def _is_input_required_type(tp: Any) -> bool: - """True when *tp* is the `InputRequiredResult` type (SEP-2322). - - Resolves a `TypeAliasType` and peels an `Annotated` wrapper first, so an - aliased arm or a metadata-carrying arm such as - ``Annotated[InputRequiredResult, Field(...)]`` is recognized as a guard - signal, not just the bare class. - """ - tp = _unwrap_type_alias(tp) +def _unwrap_model(tp: Any) -> type[BaseModel] | None: + """Unwrap ``Annotated`` and return the underlying Pydantic model, if any.""" if get_origin(tp) is Annotated: - tp = _unwrap_type_alias(get_args(tp)[0]) - return isinstance(tp, type) and issubclass(tp, mcp_types.InputRequiredResult) + return _unwrap_model(get_args(tp)[0]) + if isinstance(tp, type) and issubclass(tp, BaseModel): + return tp + return None -def _contains_input_required(tp: Any) -> bool: - """True when `InputRequiredResult` appears anywhere in *tp*. +def _resolve_output_by_alias(tp: Any) -> bool: + """Resolve ``by_alias`` for the output schema of return type *tp*. - Recurses through `TypeAliasType`, `Annotated`, and unions so a guard arm is - found even when factored through a composed alias (``str | Value`` where - ``Value = int | InputRequiredResult``). + Unwraps ``Annotated`` and ``Optional``/``Union`` wrappers to find the + underlying Pydantic model so the generated schema honors the model's + ``serialize_by_alias`` config — keeping it consistent with how the runtime + result is serialized. Containers (``list[Model]`` etc.) are not unwrapped: + their schema keeps the default, matching the runtime path which only + special-cases a directly-returned model. + + Known limitation: a single schema is generated with one ``by_alias`` value, + while the runtime resolves the alias mode per returned value. They cannot + diverge for a plain single-model return, but a union return can produce more + than one runtime alias mode that no single schema can describe: + + - distinct models with *conflicting* ``serialize_by_alias`` (e.g. ``A | B`` + where ``A`` opts out but ``B`` opts in), and + - a model arm alongside a container arm (e.g. ``Model | list[Model]``): + a directly-returned model honors its config, but a returned ``list`` is + serialized with the default alias mode, so the two variants disagree. + + Pydantic's schema generator does not consult per-model ``serialize_by_alias`` + and the runtime does not recurse into containers, so honoring every variant + would require per-arm schema assembly. This is an accepted edge; single-model + returns and unions whose arms all resolve to the same mode are consistent. """ - tp = _unwrap_type_alias(tp) - if _is_input_required_type(tp): - return True origin = get_origin(tp) - if origin is Union or origin is types.UnionType or origin is Annotated: - return any(_contains_input_required(a) for a in get_args(tp)) - return False - - -def _residual_union_arms(tp: Any) -> list[Any]: - """Flatten a (possibly aliased/nested) union into its non-guard arms. - - Every `InputRequiredResult` arm is dropped at any depth, and aliased union - arms are flattened inline so the residual is a flat union of data arms. - """ - arms: list[Any] = [] - for arm in get_args(_unwrap_type_alias(tp)): - if _is_input_required_type(arm): - continue - unwrapped = _unwrap_type_alias(arm) - arm_origin = get_origin(unwrapped) - if arm_origin is Union or arm_origin is types.UnionType: - arms.extend(_residual_union_arms(unwrapped)) - elif arm_origin is Annotated: - arms.append(_strip_input_required(arm)) - else: - arms.append(arm) - return arms - - -def _strip_input_required(tp: Any) -> Any: - """Remove `InputRequiredResult` arms from a union return annotation. - - A guard tool typically annotates its return as ``X | InputRequiredResult``; - the ``InputRequiredResult`` arm is a suspend signal, not output data, so it - is dropped before schema derivation. Stripping recurses through - `TypeAliasType` and nested unions, so a guard arm factored through an alias - (even ``str | Value`` where ``Value = int | InputRequiredResult``) is still - removed. A non-union annotation, or one with no such arm, is returned - unchanged. A bare ``InputRequiredResult`` annotation (no other arm) is left - intact and suppressed downstream like other non-serializable return types. - """ - if not _contains_input_required(tp): - return tp - unwrapped = _unwrap_type_alias(tp) - origin = get_origin(unwrapped) if origin is Annotated: - # Annotated[X | InputRequiredResult, meta] — strip inside, keep metadata. - inner, *metadata = get_args(unwrapped) - return Annotated[(_strip_input_required(inner), *metadata)] - if origin is not Union and origin is not types.UnionType: - # A bare InputRequiredResult (possibly via a `type X = ...` alias): left - # intact, but returned de-aliased so the downstream subclass/exact-type - # suppression recognizes it and emits no output schema. - return unwrapped - residual = _residual_union_arms(unwrapped) - if not residual: - return tp - if len(residual) == 1: - return residual[0] - return Union[tuple(residual)] # noqa: UP007 - - -class _ToolOutputSchemaGenerator(GenerateJsonSchema): - """Generate each model's schema with its configured serialization aliases. - - Pydantic's serializer consults ``serialize_by_alias`` per model, while its - JSON Schema API otherwise applies one ``by_alias`` value to the whole tree. - """ - - def model_schema(self, schema: core_schema.ModelSchema) -> JsonSchemaValue: - previous_by_alias = self.by_alias - configured = schema["cls"].model_config.get("serialize_by_alias") - self.by_alias = False if configured is None else configured - try: - return super().model_schema(schema) - finally: - self.by_alias = previous_by_alias - - def dataclass_schema(self, schema: core_schema.DataclassSchema) -> JsonSchemaValue: - previous_by_alias = self.by_alias - configured = (schema.get("config") or {}).get("serialize_by_alias") - self.by_alias = False if configured is None else configured - try: - return super().dataclass_schema(schema) - finally: - self.by_alias = previous_by_alias + return _resolve_output_by_alias(get_args(tp)[0]) + if origin is Union or origin is types.UnionType: + for arg in get_args(tp): + model = _unwrap_model(arg) + if model is not None: + return resolve_serialize_by_alias(model) + return True + return resolve_serialize_by_alias(tp) T = TypeVarExt("T", default=Any) @@ -323,26 +251,6 @@ class ParsedFunction: ): properties[param_name]["description"] = param_desc - # Auto-populate the create-then-pass contract onto `SessionId`-annotated - # parameters so an agent learns it straight from the schema. Append to any - # author-provided description rather than clobbering it. - from fastmcp.server.sessions import ( - SESSION_ID_DESCRIPTION, - session_id_parameter_names, - ) - - properties = input_schema.get("properties", {}) - for param_name in session_id_parameter_names(fn): - if param_name not in properties: - continue - existing = properties[param_name].get("description") - if not existing: - properties[param_name]["description"] = SESSION_ID_DESCRIPTION - elif SESSION_ID_DESCRIPTION not in existing: - properties[param_name]["description"] = ( - f"{existing}\n\n{SESSION_ID_DESCRIPTION}" - ) - output_schema = None # Get the return annotation from the signature sig = inspect.signature(fn) @@ -362,13 +270,6 @@ class ParsedFunction: # Save original for return_type before any schema-related replacement original_output_type = output_type - # An `InputRequiredResult` return arm (SEP-2322 guard tools) is a - # control-flow signal, not data: strip it so the residual arms drive - # output-schema derivation (mirrors the SDK's func_metadata). The tool - # body still returns it at runtime; the tool pipeline passes it through - # to the wire without touching the output schema. - output_type = _strip_input_required(output_type) - if output_type not in (inspect._empty, None, Any, ...): # bytes can't be represented as structured JSON output — skip schema if _contains_bytes_type(output_type): @@ -379,7 +280,7 @@ class ParsedFunction: # so we handle subclass matching explicitly here. We also need # to handle composite types like ``Column | None`` and # ``Annotated[PrefabApp, ...]`` by recursing into their args. - if _contains_prefab_type(output_type): + if _PREFAB_TYPES and _contains_prefab_type(output_type): output_type = _UnserializableType # ToolResult subclasses should suppress schema generation just @@ -387,22 +288,6 @@ class ParsedFunction: if is_class_member_of_type(output_type, ToolResult): output_type = _UnserializableType - # A bare CallToolResult gives the tool full protocol-level control - # over its response, so there is no FastMCP output schema to infer. - if isinstance(output_type, type) and issubclass( - output_type, mcp_types.CallToolResult - ): - output_type = _UnserializableType - - # If InputRequiredResult survives stripping in any wrapping — bare, - # via a `type X = ...` alias, Annotated, or a subclass — it is a - # guard-only return with no output data (a union would have had its - # guard arms stripped above). Suppress the schema wholesale; matching - # `run()`'s subclass-aware control handling and covering every alias - # shape that exact-match replace_type below would miss. - if _contains_input_required(output_type): - output_type = _UnserializableType - # there are a variety of types that we don't want to attempt to # serialize because they are either used by FastMCP internally, # or are MCP content types that explicitly don't form structured @@ -421,20 +306,19 @@ class ParsedFunction: mcp_types.AudioContent, mcp_types.ResourceLink, mcp_types.EmbeddedResource, - # A guard tool's suspend signal is control flow, not - # output data (any residual bare arm is suppressed). - mcp_types.InputRequiredResult, + *_PREFAB_TYPES, ), _UnserializableType, ), ) try: + # Honor the model's serialize_by_alias config so the schema's + # field names match the serialized result (see base.py). + by_alias = _resolve_output_by_alias(clean_output_type) type_adapter = get_cached_typeadapter(clean_output_type) base_schema = type_adapter.json_schema( - mode="serialization", - by_alias=False, - schema_generator=_ToolOutputSchemaGenerator, + mode="serialization", by_alias=by_alias ) # Generate schema for wrapped type if it's non-object @@ -446,9 +330,7 @@ class ParsedFunction: wrapped_type = _WrappedResult[clean_output_type] wrapped_adapter = get_cached_typeadapter(wrapped_type) output_schema = wrapped_adapter.json_schema( - mode="serialization", - by_alias=False, - schema_generator=_ToolOutputSchemaGenerator, + mode="serialization", by_alias=by_alias ) output_schema["x-fastmcp-wrap-result"] = True else: diff --git a/fastmcp_slim/fastmcp/tools/function_tool.py b/fastmcp_slim/fastmcp/tools/function_tool.py index e5787f466..dd4c236e1 100644 --- a/fastmcp_slim/fastmcp/tools/function_tool.py +++ b/fastmcp_slim/fastmcp/tools/function_tool.py @@ -10,6 +10,7 @@ from dataclasses import dataclass, field from functools import lru_cache from types import MethodType from typing import ( + TYPE_CHECKING, Annotated, Any, Literal, @@ -22,7 +23,6 @@ from typing import ( ) import anyio -import mcp_types from mcp.shared.exceptions import MCPError from mcp_types import Icon, ToolAnnotations from pydantic import Field, TypeAdapter @@ -32,7 +32,6 @@ from pydantic.json_schema import SkipJsonSchema from fastmcp.decorators import get_fastmcp_meta from fastmcp.exceptions import ValidationError from fastmcp.tools.base import ( - InputRequiredToolResult, Tool, ToolResult, ) @@ -52,6 +51,10 @@ from fastmcp.utilities.types import ( logger = get_logger(__name__) +if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution + class _ToolBodyError(Exception): """Marks a ``pydantic.ValidationError`` raised while executing a tool's body. @@ -197,6 +200,7 @@ def _resolve_param_hints(fn: Callable[..., Any]) -> dict[str, Any]: class FunctionTool(Tool): fn: SkipJsonSchema[Callable[..., Any]] + return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None run_in_thread: Annotated[ bool, Field( @@ -365,15 +369,7 @@ class FunctionTool(Tool): ) async def run(self, arguments: dict[str, Any]) -> ToolResult: - """Run the tool with arguments. - - A tool body may return an `InputRequiredResult` (SEP-2322) to ask the - client for input. Under the stateless multi-round-trip protocol that ask - is the full result of this leg, so it is wrapped in an - `InputRequiredToolResult` (a `ToolResult` subclass) rather than - serialized as content; the ask flows through the middleware chain as an - ordinary result and the wire handler returns it to the client unmodified. - """ + """Run the tool with arguments.""" from fastmcp.server.dependencies import without_injected_parameters wrapper_fn = without_injected_parameters( @@ -386,29 +382,6 @@ class FunctionTool(Tool): exec_is_async = is_coroutine_function(wrapper_fn) strict = _strict_input_validation() - result = await self._run_body( - type_adapter, exec_is_async, arguments, strict=strict - ) - - # An `InputRequiredResult` is the full result of this multi-round-trip - # leg (SEP-2322), not tool-output data: wrap it in an - # `InputRequiredToolResult` so it flows through the middleware chain as - # an ordinary result instead of being serialized as content. The wire - # handler reads it back out (see `_on_call_tool`). - if isinstance(result, mcp_types.InputRequiredResult): - return InputRequiredToolResult(result) - - return self.convert_result(result) - - async def _run_body( - self, - type_adapter: TypeAdapter[Any], - exec_is_async: bool, - arguments: dict[str, Any], - *, - strict: bool, - ) -> Any: - """Validate arguments and execute the body, applying any timeout.""" try: if self.timeout is not None: try: @@ -445,7 +418,7 @@ class FunctionTool(Tool): assert original is not None raise original from original.__cause__ - return result + return self.convert_result(result) async def _execute( self, @@ -505,6 +478,88 @@ class FunctionTool(Tool): return list(result) return result + def register_with_docket(self, docket: Docket) -> None: + """Register this tool with docket for background execution. + + Registers the raw function so Docket sees and resolves ALL + dependencies — both FastMCP's (CurrentContext, Progress) and + Docket-native ones (Retry, Timeout, ConcurrencyLimit). + """ + if not self.task_config.supports_tasks(): + return + docket.register(self.fn, names=[self.key]) + + async def add_to_docket( + self, + docket: Docket, + arguments: dict[str, Any], + *, + fn_key: str | None = None, + task_key: str | None = None, + **kwargs: Any, + ) -> Execution: + """Schedule this tool for background execution via docket. + + FunctionTool splats the arguments dict since .fn expects **kwargs. + + Args: + docket: The Docket instance + arguments: Tool arguments + fn_key: Function lookup key in Docket registry (defaults to self.key) + task_key: Redis storage key for the result + **kwargs: Additional kwargs passed to docket.add() + """ + lookup_key = fn_key or self.key + if task_key: + kwargs["key"] = task_key + return await docket.add(lookup_key, **kwargs)(**arguments) + + def coerce_task_arguments( + self, arguments: dict[str, Any], *, strict: bool = False + ) -> dict[str, Any]: + """Validate client arguments against their declared parameter types. + + The synchronous ``run()`` path validates arguments through the + function's Pydantic TypeAdapter, so a parameter typed as a model + arrives as a model instance. The task path hands the raw arguments to + Docket, which binds them to the function signature without coercion — + so without this a model-typed parameter would reach the function as a + raw dict (#4349). ``submit_to_docket`` calls this up front so coerced + values are what get queued, and validation errors surface before any + task state is created. Coerced values survive the trip to the worker + because Docket serializes task arguments with cloudpickle. + + ``strict`` mirrors the synchronous path's ``strict_input_validation`` + handling: when set, arguments are validated in strict mode so lax + coercions (e.g. the string ``"1"`` into an ``int``) are rejected at + submission rather than silently coerced and queued. + + Injected dependency parameters (Context, Depends()) are excluded via + the same wrapper used by the synchronous path, so only client-supplied + arguments are coerced and Docket's dependency resolution is untouched. + """ + from fastmcp.server.dependencies import without_injected_parameters + + wrapper_fn = without_injected_parameters( + self.fn, run_in_thread=self.run_in_thread + ) + hints = _resolve_param_hints(wrapper_fn) + + coerced = dict(arguments) + for name, value in arguments.items(): + annotation = hints.get(name) + if annotation is None: + continue + adapter = get_cached_typeadapter(annotation) + try: + coerced[name] = adapter.validate_python(value, strict=strict) + except PydanticValidationError as e: + # Argument coercion failure on the task path is a bad call, just + # like the synchronous path — surface it as fastmcp's + # ValidationError so it is classified consistently (see #4128). + raise ValidationError(str(e), log_level=logging.WARNING) from e + return coerced + @overload def tool(fn: F) -> F: ... diff --git a/fastmcp_slim/fastmcp/tools/tool_transform.py b/fastmcp_slim/fastmcp/tools/tool_transform.py index 436f3f2d3..425a970b3 100644 --- a/fastmcp_slim/fastmcp/tools/tool_transform.py +++ b/fastmcp_slim/fastmcp/tools/tool_transform.py @@ -7,7 +7,7 @@ from copy import deepcopy from dataclasses import dataclass from typing import Annotated, Any, Literal, cast -import mcp_types +import pydantic_core from mcp_types import ToolAnnotations from pydantic import ConfigDict from pydantic.fields import Field @@ -15,9 +15,10 @@ from pydantic.functional_validators import BeforeValidator from pydantic.json_schema import SkipJsonSchema from fastmcp.tools.base import ( - InputRequiredToolResult, Tool, ToolResult, + _convert_to_content, + resolve_serialize_by_alias, ) from fastmcp.tools.function_parsing import ParsedFunction from fastmcp.utilities.async_utils import ( @@ -239,50 +240,6 @@ class ArgTransformConfig(FastMCPBaseModel): return ArgTransform(**self.model_dump(exclude_unset=True)) # pyright: ignore[reportAny] -#: Meta namespaces the framework owns. An override replaces the caller-facing -#: meta wholesale, but these carry a component's app membership, identity, and -#: visibility — what intermediaries use to recognize a tool they are -#: forwarding. Both are needed together: an identity that survives a rename -#: while its ``ui.visibility`` marker does not leaves a tool that can be named -#: but no longer answers to its identity. -_FRAMEWORK_META_NAMESPACES = ("fastmcp", "ui") - - -def _apply_meta_override( - source_meta: dict[str, Any] | None, - override: dict[str, Any] | None | NotSetT, -) -> dict[str, Any] | None: - """Apply a transform's ``meta=`` override, preserving framework namespaces. - - An override replaces the caller-facing meta wholesale, which is what users - expect. Framework-owned namespaces are carried across regardless, since a - transform that renames a tool must not silently unwire it — values the - override supplies for those namespaces still win key by key. - """ - if isinstance(override, NotSetT): - return source_meta - - source = source_meta or {} - preserved = { - namespace: dict(source[namespace]) - for namespace in _FRAMEWORK_META_NAMESPACES - if isinstance(source.get(namespace), dict) and source[namespace] - } - - if override is None: - return preserved or None - - merged = dict(override) - for namespace, source_values in preserved.items(): - override_values = override.get(namespace) - merged[namespace] = ( - {**source_values, **override_values} - if isinstance(override_values, dict) - else source_values - ) - return merged - - class TransformedTool(Tool): """A tool that is transformed from another tool. @@ -366,16 +323,6 @@ class TransformedTool(Tool): if inspect.isawaitable(result): result = await result - # A multi-round-trip ask (SEP-2322) is not output data: it must - # reach the wire handler intact, never reshaped by output_schema - # (which would rebuild it as a plain empty ToolResult and drop the - # input_required payload). A custom transform fn may return the raw - # `InputRequiredResult`, like any tool body; wrap it the same way. - if isinstance(result, InputRequiredToolResult): - return result - if isinstance(result, mcp_types.InputRequiredResult): - return InputRequiredToolResult(result) - # If transform function returns ToolResult, respect our output_schema setting if isinstance(result, ToolResult): if self.output_schema is None: @@ -391,7 +338,40 @@ class TransformedTool(Tool): else: return result - return self.convert_result(result) + # Otherwise convert to content and create ToolResult with proper structured content + + unstructured_result = _convert_to_content(result) + + structured_output = None + # First handle structured content based on output schema, if any + if self.output_schema is not None: + if self.output_schema.get("x-fastmcp-wrap-result"): + # Schema says wrap - serialize the inner result first (so its + # serialize_by_alias config is honored) before nesting, since + # wrapping in a dict would otherwise mask the model's config. + structured_output = { + "result": pydantic_core.to_jsonable_python( + result, by_alias=resolve_serialize_by_alias(result) + ) + } + else: + structured_output = result + # If no output schema, try to serialize the result. If it is a dict, use + # it as structured content. If it is not a dict, ignore it. + if structured_output is None: + try: + structured_output = pydantic_core.to_jsonable_python( + result, by_alias=resolve_serialize_by_alias(result) + ) + if not isinstance(structured_output, dict): + structured_output = None + except Exception: + pass + + return ToolResult( + content=unstructured_result, + structured_content=structured_output, + ) finally: _current_tool.reset(token) @@ -598,14 +578,13 @@ class TransformedTool(Tool): description if not isinstance(description, NotSetT) else tool.description ) final_title = title if not isinstance(title, NotSetT) else tool.title - final_meta = _apply_meta_override(tool.meta, meta) + final_meta = meta if not isinstance(meta, NotSetT) else tool.meta final_annotations = ( annotations if not isinstance(annotations, NotSetT) else tool.annotations ) transformed_tool = cls( fn=final_fn, - return_type=parsed_fn.return_type if parsed_fn is not None else None, forwarding_fn=forwarding_fn, parent_tool=tool, name=final_name, @@ -716,8 +695,7 @@ class TransformedTool(Tool): schema = { "type": "object", "properties": new_props, - # Iterate props (not the set) for deterministic ordering - "required": [p for p in new_props if p in new_required], + "required": list(new_required), "additionalProperties": False, } @@ -909,11 +887,7 @@ class TransformedTool(Tool): result = { "type": "object", "properties": merged_props, - # Iterate props (not the set) for deterministic ordering; keep any - # required names not present in properties (sorted) rather than - # silently dropping them. - "required": [p for p in merged_props if p in final_required] - + sorted(final_required - set(merged_props)), + "required": list(final_required), "additionalProperties": False, } diff --git a/fastmcp_slim/fastmcp/types.py b/fastmcp_slim/fastmcp/types.py index b7ac46d88..4b33e7cb2 100644 --- a/fastmcp_slim/fastmcp/types.py +++ b/fastmcp_slim/fastmcp/types.py @@ -22,6 +22,84 @@ from __future__ import annotations from typing import Annotated +from mcp_types import ( + Annotations as Annotations, +) +from mcp_types import ( + AudioContent as AudioContent, +) +from mcp_types import ( + BlobResourceContents as BlobResourceContents, +) +from mcp_types import ( + CallToolResult as CallToolResult, +) +from mcp_types import ( + Completion as Completion, +) +from mcp_types import ( + ContentBlock as ContentBlock, +) +from mcp_types import ( + CreateMessageResult as CreateMessageResult, +) +from mcp_types import ( + EmbeddedResource as EmbeddedResource, +) +from mcp_types import ( + ErrorData as ErrorData, +) +from mcp_types import ( + GetPromptResult as GetPromptResult, +) +from mcp_types import ( + Icon as Icon, +) +from mcp_types import ( + ImageContent as ImageContent, +) +from mcp_types import ( + Prompt as Prompt, +) +from mcp_types import ( + PromptMessage as PromptMessage, +) +from mcp_types import ( + ReadResourceResult as ReadResourceResult, +) +from mcp_types import ( + Resource as Resource, +) +from mcp_types import ( + ResourceLink as ResourceLink, +) +from mcp_types import ( + ResourceTemplate as ResourceTemplate, +) +from mcp_types import ( + Root as Root, +) +from mcp_types import ( + SamplingCapability as SamplingCapability, +) +from mcp_types import ( + SamplingMessage as SamplingMessage, +) +from mcp_types import ( + TextContent as TextContent, +) +from mcp_types import ( + TextResourceContents as TextResourceContents, +) +from mcp_types import ( + Tool as Tool, +) +from mcp_types import ( + ToolAnnotations as ToolAnnotations, +) +from mcp_types import ( + ToolResultContent as ToolResultContent, +) from pydantic import Field Textarea = Annotated[str, Field(json_schema_extra={"format": "textarea"})] @@ -32,5 +110,31 @@ Produces `"format": "textarea"` in the JSON Schema, which """ __all__ = [ + "Annotations", + "AudioContent", + "BlobResourceContents", + "CallToolResult", + "Completion", + "ContentBlock", + "CreateMessageResult", + "EmbeddedResource", + "ErrorData", + "GetPromptResult", + "Icon", + "ImageContent", + "Prompt", + "PromptMessage", + "ReadResourceResult", + "Resource", + "ResourceLink", + "ResourceTemplate", + "Root", + "SamplingCapability", + "SamplingMessage", + "TextContent", + "TextResourceContents", "Textarea", + "Tool", + "ToolAnnotations", + "ToolResultContent", ] diff --git a/fastmcp_slim/fastmcp/utilities/asgi_transport.py b/fastmcp_slim/fastmcp/utilities/asgi_transport.py deleted file mode 100644 index cb8ee1ab4..000000000 --- a/fastmcp_slim/fastmcp/utilities/asgi_transport.py +++ /dev/null @@ -1,322 +0,0 @@ -"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx. - -Ported from the MCP Python SDK's test suite (`tests/interaction/transports/_bridge.py`, -MIT licensed). - -`httpx2.ASGITransport` runs the application to completion and only then hands the buffered -response to the caller, so a server that streams its response — as the streamable HTTP -transport's SSE responses do — can never converse with the client mid-request: a -server-initiated request nested inside a still-open call deadlocks. -`StreamingASGITransport` removes that limitation by running the application as a background -task and forwarding every `http.response.body` chunk to the client the moment it is sent. -Everything happens on the one event loop: no sockets, no threads, no sleeps. - -The behavioural contract: - -- The request body is buffered before the application is invoked (MCP requests are small - JSON documents); the response streams chunk by chunk. -- Closing the response — or the whole client — delivers `http.disconnect` to the - application, exactly as a real server sees when its peer goes away. -- An exception the application raises before sending `http.response.start` fails the - originating request with that same exception. After the response has started, a failure - is visible to the client only through the response itself (status code, truncated body) — - the same signal a real server over a real socket would give. - -The transport owns an anyio task group for the application tasks; it is opened and closed by -`httpx2.AsyncClient`'s own context manager, so the client must be used as a context manager. -Closing the transport cancels every running application task by default; set -`cancel_on_close=False` to wait for the application's own disconnect handling instead, which -is what the legacy SSE transport relies on for resource cleanup. -""" - -from __future__ import annotations - -import asyncio -import math -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from types import TracebackType - -import anyio -import anyio.abc -import httpx2 -from anyio.streams.memory import MemoryObjectReceiveStream -from starlette.types import ASGIApp, Message, Scope - - -class _StreamingResponseBody(httpx2.AsyncByteStream): - """A response body that yields chunks as the application produces them. - - Closing it tells the application the client has gone away (`http.disconnect`), - mirroring a peer that drops the connection mid-response. - """ - - def __init__( - self, - chunks: MemoryObjectReceiveStream[bytes], - client_disconnected: anyio.Event, - ) -> None: - self._chunks = chunks - self._client_disconnected = client_disconnected - - async def __aiter__(self) -> AsyncIterator[bytes]: - async for chunk in self._chunks: - yield chunk - - async def aclose(self) -> None: - self._client_disconnected.set() - await self._chunks.aclose() - - -class StreamingASGITransport(httpx2.AsyncBaseTransport): - """Drive an ASGI application in-process, streaming each response as it is produced. - - This is an `httpx2` transport, so it plugs into anything that accepts an - `httpx2.AsyncClient` — including FastMCP's client transports via their - `httpx_client_factory` argument. - - Args: - app: The ASGI application to drive (e.g. `FastMCP.http_app()`). - cancel_on_close: When True (the default), closing the transport cancels every - application task still running, so harness teardown can never hang. Set to - False to wait for the application's own disconnect handling to complete - instead, which the legacy SSE server transport relies on for cleanup. - - Example: - Drive a FastMCP server's real HTTP app with no sockets: - ```python - import httpx2 - from fastmcp import FastMCP - from fastmcp.utilities.asgi_transport import StreamingASGITransport - - mcp = FastMCP("test") - app = mcp.http_app(transport="http") - - async with app.router.lifespan_context(app): - transport = StreamingASGITransport(app) - async with httpx2.AsyncClient( - transport=transport, base_url="http://testserver" - ) as client: - response = await client.get("/mcp") - ``` - """ - - _task_group: anyio.abc.TaskGroup - - def __init__(self, app: ASGIApp, *, cancel_on_close: bool = True) -> None: - self._app = app - self._cancel_on_close = cancel_on_close - - async def __aenter__(self) -> StreamingASGITransport: - self._task_group = anyio.create_task_group() - await self._task_group.__aenter__() - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None = None, - exc_value: BaseException | None = None, - traceback: TracebackType | None = None, - ) -> None: - # httpx closes every streamed response before closing the transport, so by now each - # application task has been delivered `http.disconnect`. Either cancel immediately, - # or wait for the application's own disconnect handling to unwind. - if self._cancel_on_close: - self._task_group.cancel_scope.cancel() - await self._task_group.__aexit__(exc_type, exc_value, traceback) - - async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response: - if not isinstance(request.stream, httpx2.AsyncByteStream): - raise TypeError( - "StreamingASGITransport requires an async request stream; " - f"got {type(request.stream).__name__}." - ) - request_body = b"".join([chunk async for chunk in request.stream]) - - scope: Scope = { - "type": "http", - "asgi": {"version": "3.0"}, - "http_version": "1.1", - "method": request.method, - "scheme": request.url.scheme, - "path": request.url.path, - "raw_path": request.url.raw_path.split(b"?", maxsplit=1)[0], - "query_string": request.url.query, - "root_path": "", - "headers": [(name.lower(), value) for name, value in request.headers.raw], - "server": (request.url.host, request.url.port), - "client": ("127.0.0.1", 1234), - } - - request_delivered = False - start_received = False - client_disconnected = anyio.Event() - response_started = anyio.Event() - response_status = 0 - response_headers: list[tuple[bytes, bytes]] = [] - application_error: Exception | None = None - chunk_writer, chunk_reader = anyio.create_memory_object_stream[bytes](math.inf) - - async def receive_request() -> Message: - nonlocal request_delivered - if not request_delivered: - request_delivered = True - return { - "type": "http.request", - "body": request_body, - "more_body": False, - } - await client_disconnected.wait() - return {"type": "http.disconnect"} - - async def send_response(message: Message) -> None: - nonlocal response_status, response_headers, start_received - if message["type"] == "http.response.start": - start_received = True - response_status = message["status"] - response_headers = list(message.get("headers", [])) - response_started.set() - return - if message["type"] != "http.response.body": - raise RuntimeError(f"Unexpected ASGI message type: {message['type']}") - body: bytes = message.get("body", b"") - if body: - await chunk_writer.send(body) - if not message.get("more_body", False): - await chunk_writer.aclose() - - async def run_application() -> None: - nonlocal application_error - try: - await self._app(scope, receive_request, send_response) - except Exception as exc: - # The bridge is the application's outermost boundary: a crash must fail the - # originating request (or show up in the already-started response), never - # tear down the task group shared with every other in-flight request. - application_error = exc - finally: - response_started.set() - await chunk_writer.aclose() - - self._task_group.start_soon(run_application) - try: - await response_started.wait() - # Only a failure *before* the start message can fail the request. Once the - # response has started the client sees the failure as a truncated body, which - # is the same signal a real server over a real socket would give. - if application_error is not None and not start_received: - raise application_error - except BaseException: - # No response will be built, so close the reader the response body would have - # owned and tell the application its peer has gone away. - client_disconnected.set() - await chunk_reader.aclose() - raise - return httpx2.Response( - status_code=response_status, - headers=response_headers, - stream=_StreamingResponseBody(chunk_reader, client_disconnected), - request=request, - ) - - -@asynccontextmanager -async def run_asgi_lifespan(app: ASGIApp) -> AsyncIterator[None]: - """Run an ASGI application's lifespan, driving the protocol as a real server does. - - The application's lifespan runs inside a dedicated task for the whole duration of - the context. This matters because a lifespan typically owns cancel scopes and task - groups — anyio requires those to be exited by the task that entered them, which - rules out entering the lifespan on one task and leaving it on another (as a pytest - fixture's setup and teardown phases may do). - - Args: - app: The ASGI application whose lifespan should run. - - Raises: - RuntimeError: If the application reports `lifespan.startup.failed`, or reports - `lifespan.shutdown.failed` (or crashes during shutdown) while the context - body itself completed successfully. A failure inside the body takes - precedence and propagates unchanged. - """ - receive_queue: asyncio.Queue[Message] = asyncio.Queue() - startup_complete: asyncio.Future[None] = asyncio.get_running_loop().create_future() - shutdown_complete: asyncio.Future[None] = asyncio.get_running_loop().create_future() - - async def receive() -> Message: - return await receive_queue.get() - - async def send(message: Message) -> None: - if message["type"] == "lifespan.startup.complete": - if not startup_complete.done(): - startup_complete.set_result(None) - elif message["type"] == "lifespan.startup.failed": - if not startup_complete.done(): - startup_complete.set_exception( - RuntimeError( - f"ASGI application startup failed: {message.get('message', '')}" - ) - ) - elif message["type"] == "lifespan.shutdown.complete": - if not shutdown_complete.done(): - shutdown_complete.set_result(None) - elif message["type"] == "lifespan.shutdown.failed": - if not shutdown_complete.done(): - shutdown_complete.set_exception( - RuntimeError( - "ASGI application shutdown failed: " - f"{message.get('message', '')}" - ) - ) - - async def run_lifespan() -> None: - scope: Scope = {"type": "lifespan", "asgi": {"version": "3.0"}} - try: - await app(scope, receive, send) - except BaseException as exc: - # The app died without completing the handshake; surface that to whichever - # side is still waiting rather than hanging. - if not startup_complete.done(): - startup_complete.set_exception(exc) - if not shutdown_complete.done(): - shutdown_complete.set_exception(exc) - raise - else: - if not startup_complete.done(): - startup_complete.set_exception( - RuntimeError("ASGI application exited before completing startup") - ) - if not shutdown_complete.done(): - shutdown_complete.set_result(None) - - task = asyncio.create_task(run_lifespan()) - await receive_queue.put({"type": "lifespan.startup"}) - try: - await startup_complete - except BaseException: - task.cancel() - with anyio.CancelScope(shield=True): - await asyncio.gather(task, return_exceptions=True) - raise - - body_failed = False - try: - yield - except BaseException: - body_failed = True - raise - finally: - await receive_queue.put({"type": "lifespan.shutdown"}) - with anyio.CancelScope(shield=True): - results = await asyncio.gather( - shutdown_complete, task, return_exceptions=True - ) - # A harness must surface a broken teardown rather than swallow it — but never at - # the cost of masking the failure the body already raised, which is the one the - # caller actually needs to see. - if not body_failed: - for result in results: - if isinstance(result, BaseException) and not isinstance( - result, asyncio.CancelledError - ): - raise result diff --git a/fastmcp_slim/fastmcp/utilities/async_utils.py b/fastmcp_slim/fastmcp/utilities/async_utils.py index 978c84a6e..8fc24f49d 100644 --- a/fastmcp_slim/fastmcp/utilities/async_utils.py +++ b/fastmcp_slim/fastmcp/utilities/async_utils.py @@ -2,7 +2,7 @@ import functools import inspect -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Awaitable, Callable from typing import Any, Literal, TypeVar, overload import anyio @@ -36,55 +36,35 @@ async def call_sync_fn_in_threadpool( @overload async def gather( - awaitables: Iterable[Awaitable[T]], - *, + *awaitables: Awaitable[T], return_exceptions: Literal[True], ) -> list[T | BaseException]: ... @overload async def gather( - awaitables: Iterable[Awaitable[T]], - *, + *awaitables: Awaitable[T], return_exceptions: Literal[False] = ..., ) -> list[T]: ... async def gather( - awaitables: Iterable[Awaitable[T]], - *, + *awaitables: Awaitable[T], return_exceptions: bool = False, ) -> list[T] | list[T | BaseException]: """Run awaitables concurrently and return results in order. Uses anyio TaskGroup for structured concurrency. - ``awaitables`` is consumed lazily, one item at a time, right before each - is handed to the task group. Callers with a dynamic number of awaitables - should pass a generator expression (e.g. ``gather(f(x) for x in xs)``) - rather than a list or list comprehension: a list comprehension calls - every ``f(x)`` up front, creating a batch of coroutine objects before - this function even starts, whereas a generator expression creates each - coroutine only as this function's own scheduling loop asks for it. That - matters because coroutine creation and scheduling can be interrupted - between any two bytecode instructions by a synchronous signal handler - (for example pytest-timeout's SIGALRM-based per-test timeout). If that - happens while a whole batch of coroutines is sitting unscheduled, they - are silently abandoned and eventually trigger a "coroutine was never - awaited" warning attributed to whatever unrelated code happens to be - running when the garbage collector gets to them. Lazy consumption keeps - the window in which a created-but-unscheduled coroutine can exist as - small as possible. - Args: - awaitables: Iterable of awaitables to run concurrently. + *awaitables: Awaitables to run concurrently return_exceptions: If True, exceptions are returned in results. If False, first exception cancels all and raises. Returns: List of results in the same order as input awaitables. """ - results: list[T | BaseException] = [] + results: list[T | BaseException] = [None] * len(awaitables) # type: ignore[assignment] # ty:ignore[invalid-assignment] async def run_at(i: int, aw: Awaitable[T]) -> None: try: @@ -95,26 +75,8 @@ async def gather( else: raise - pending = enumerate(awaitables) async with anyio.create_task_group() as tg: - for i, aw in pending: - results.append(None) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - try: - tg.start_soon(run_at, i, aw) - except BaseException: - # `aw` was just created (possibly moments ago, by the - # generator's own iteration) but never handed off - close it - # explicitly so it isn't silently garbage collected later. - if inspect.iscoroutine(aw): - aw.close() - # Lazy consumption keeps the leak window small, but a caller - # that passed an already-built sequence has coroutines sitting - # behind this one that were never scheduled either. Draining - # the iterator closes them too, so `gather` cannot leak - # regardless of how eagerly its argument was constructed. - for _, remaining in pending: - if inspect.iscoroutine(remaining): - remaining.close() - raise + for i, aw in enumerate(awaitables): + tg.start_soon(run_at, i, aw) return results diff --git a/fastmcp_slim/fastmcp/utilities/authorization.py b/fastmcp_slim/fastmcp/utilities/authorization.py index 5ae4ae184..4e193c71d 100644 --- a/fastmcp_slim/fastmcp/utilities/authorization.py +++ b/fastmcp_slim/fastmcp/utilities/authorization.py @@ -9,9 +9,9 @@ from __future__ import annotations import inspect import logging -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast from fastmcp.exceptions import AuthorizationError @@ -47,270 +47,55 @@ class AuthContext: AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]] -class _ScopeAwareCheck: - """Base for auth checks that can name the scopes a token is missing. - - Ordinary auth checks are opaque booleans: on denial they reveal nothing - about *why*. Scope-based checks expose their unmet requirements through - `missing_scopes` so a shortfall can be surfaced as a spec-correct - ``insufficient_scope`` step-up (SEP-2350 / RFC 6750 §3) naming exactly what - the caller must re-authorize for. - """ - - def missing_scopes(self, ctx: AuthContext) -> set[str]: - """Return the required scopes the token lacks (empty if satisfied). - - An absent token yields an empty set: a missing token is an - authentication problem, not a scope shortfall, and must not be turned - into an ``insufficient_scope`` challenge (RFC 6750 §3.1). - """ - raise NotImplementedError - - -class _RequireScopes(_ScopeAwareCheck): - """Callable auth check requiring all of a fixed set of OAuth scopes.""" - - def __init__(self, scopes: tuple[str, ...]) -> None: - self.required_scopes: frozenset[str] = frozenset(scopes) - - def __call__(self, ctx: AuthContext) -> bool: - if ctx.token is None: - return False - return self.required_scopes.issubset(set(ctx.token.scopes)) - - def missing_scopes(self, ctx: AuthContext) -> set[str]: - if ctx.token is None: - return set() - return set(self.required_scopes) - set(ctx.token.scopes) - - -class _RestrictTag(_ScopeAwareCheck): - """Callable auth check requiring scopes only when a component has a tag.""" - - def __init__(self, tag: str, scopes: list[str]) -> None: - self.tag = tag - self.required_scopes: frozenset[str] = frozenset(scopes) - - def __call__(self, ctx: AuthContext) -> bool: - if self.tag not in ctx.component.tags: - return True - if ctx.token is None: - return False - return self.required_scopes.issubset(set(ctx.token.scopes)) - - def missing_scopes(self, ctx: AuthContext) -> set[str]: - if self.tag not in ctx.component.tags or ctx.token is None: - return set() - return set(self.required_scopes) - set(ctx.token.scopes) - - -class _RequireRoles: - """Callable auth check requiring all of a fixed set of roles. - - Deliberately not a :class:`_ScopeAwareCheck`. Roles are not scopes and - cannot be requested through OAuth, so a shortfall has no spec-correct - step-up representation. - """ - - def __init__( - self, - roles: tuple[str, ...], - extract: Callable[[dict[str, Any]], Iterable[str]], - ) -> None: - self.required_roles: frozenset[str] = frozenset(roles) - self._extract = extract - - def __call__(self, ctx: AuthContext) -> bool: - if ctx.token is None: - return False - try: - extracted = self._extract(ctx.token.claims) - # A provider that stores a single role as a bare string satisfies - # `Iterable[str]`, but iterating it yields characters: "admin" - # would deny an "admin" requirement and grant an "a" one. Treat a - # string as the single role it plainly means. - if isinstance(extracted, str): - extracted = [extracted] - granted = set(extracted) - except (KeyError, IndexError, TypeError): - # A caller whose token simply lacks the claim is an ordinary - # denial, not a broken check: return False rather than letting - # `_evaluate_check` log a warning on every unauthorized request. - return False - return self.required_roles.issubset(granted) - - def require_scopes(*scopes: str) -> AuthCheck: """Require all of the given OAuth scopes.""" - return _RequireScopes(scopes) + required = set(scopes) + def check(ctx: AuthContext) -> bool: + if ctx.token is None: + return False + return required.issubset(set(ctx.token.scopes)) -def require_roles( - *roles: str, - extract: Callable[[dict[str, Any]], Iterable[str]], -) -> AuthCheck: - """Require all of the given roles, read from the token's claims. - - Roles and groups are not part of OIDC, so every identity provider puts them - somewhere different: `realm_access.roles` on Keycloak, `roles` on Microsoft - Entra, `cognito:groups` on AWS Cognito, `permissions` or a namespaced custom - claim on Auth0. `extract` receives the token's claims and returns the - caller's roles, which keeps that provider-specific knowledge at the call - site instead of guessing it here. - - ```python - from fastmcp.server.auth import require_roles - - keycloak = require_roles("admin", extract=lambda c: c["realm_access"]["roles"]) - cognito = require_roles("admins", extract=lambda c: c["cognito:groups"]) - ``` - - A token missing the claim entirely is denied rather than treated as an - error, so `extract` may index into the claims without guarding. An - extractor returning a bare string is treated as one role, since a provider - that stores a single role as a scalar is common. - - Unlike `require_scopes`, this check cannot signal a shortfall: OAuth has no - way to request a role, so there is no `insufficient_scope` challenge to - emit. A role denial is therefore reported as a plain `AuthorizationError`, - and it suppresses any scope shortfall alongside it — a caller blocked by - their role must not be told to go obtain a scope that would not help. - Scope shortfalls are still reported normally whenever the role check - passes. - - Args: - *roles: Roles the caller must hold. All are required (AND logic). - extract: Callable mapping the token's claims to the caller's roles. - - Raises: - ValueError: If no roles are given, which would allow any authenticated - caller and is more likely a mistake than an intent. - """ - if not roles: - raise ValueError( - "require_roles() needs at least one role; a check with no roles " - "would admit any authenticated caller." - ) - return _RequireRoles(roles, extract) + return check def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck: """Require scopes when the accessed component has a specific tag.""" - return _RestrictTag(tag, scopes) + required = set(scopes) + def check(ctx: AuthContext) -> bool: + if tag not in ctx.component.tags: + return True + if ctx.token is None: + return False + return required.issubset(set(ctx.token.scopes)) -def scope_requirements( - checks: AuthCheck | list[AuthCheck], - ctx: AuthContext, -) -> list[str] | None: - """Scopes a check list requires but the token lacks, without running it. - - Returns ``None`` when the list contains any opaque (non-scope) check. Such a - check might deny for a reason unrelated to scopes, and evaluating it here - would run authorization logic — with whatever side effects it carries — - outside its normal place in the chain. Since its verdict is unknown, its - siblings' scopes must not be disclosed either, so the whole list is withheld. - - When every check is scope-aware, the result is their combined shortfall, - computed purely from the token and component (an empty list means the list is - already satisfied). This lets a shortfall be aggregated across authorization - layers without evaluating anything that would otherwise be skipped. - """ - check_list = [checks] if not isinstance(checks, list) else checks - check_list = cast(list[AuthCheck], check_list) - - missing: set[str] = set() - for check in check_list: - if not isinstance(check, _ScopeAwareCheck): - return None - missing |= check.missing_scopes(ctx) - return sorted(missing) - - -async def _evaluate_check(check: AuthCheck, ctx: AuthContext) -> bool: - """Evaluate a single auth check, masking unexpected failures as denial. - - An ``AuthorizationError`` is the check's deliberate denial and propagates. - Any other exception is a bug in the check; it is logged and treated as a - denial so a broken check fails closed. - """ - try: - result = check(ctx) - if inspect.isawaitable(result): - result = await result - except AuthorizationError: - raise - except Exception: - logger.warning( - f"Auth check {getattr(check, '__name__', repr(check))} " - "raised an unexpected exception", - exc_info=True, - ) - return False - return bool(result) - - -async def run_auth_checks_with_shortfall( - checks: AuthCheck | list[AuthCheck], - ctx: AuthContext, -) -> tuple[bool, list[str]]: - """Run auth checks with AND logic, classifying the denial cause. - - Returns ``(authorized, missing_scopes)``. ``missing_scopes`` names every - scope the caller must obtain to satisfy *all* scope requirements at once: - the union of the shortfalls across every scope-aware check, not just the - first one to fail. Reporting only the first would strand a caller in a - step-up loop — it obtains that scope, retries, and is denied again for the - next — so the union is what makes a single re-authorization converge. - - The challenge is withheld entirely (an empty list, which the caller surfaces - as a plain ``AuthorizationError``) unless every non-scope check passes. A - custom policy denial — a tenant check, say — must never be reported as an - ``insufficient_scope`` shortfall, and must never name the scopes of a - component the caller could not otherwise reach. To guarantee that, the - opaque checks are all evaluated before any scope is disclosed; a shortfall - is only reported once they have all passed. - - An ``AuthorizationError`` raised by a check propagates unchanged. - """ - check_list = [checks] if not isinstance(checks, list) else checks - check_list = cast(list[AuthCheck], check_list) - - scope_shortfall = False - for check in check_list: - if await _evaluate_check(check, ctx): - continue - if not isinstance(check, _ScopeAwareCheck): - # An opaque denial dominates: deny without disclosing any scope. - return False, [] - # Keep going. The remaining opaque checks still have to pass before a - # scope shortfall may be disclosed, and the remaining scope checks - # contribute to the union. - scope_shortfall = True - - if not scope_shortfall: - return True, [] - - # Every opaque check passed, so naming the shortfall is safe. `missing_scopes` - # is a pure comparison against the token and returns an empty set for checks - # that passed, so unioning across all of them yields exactly the unmet scopes. - missing: set[str] = set() - for check in check_list: - if isinstance(check, _ScopeAwareCheck): - missing |= check.missing_scopes(ctx) - return False, sorted(missing) + return check async def run_auth_checks( checks: AuthCheck | list[AuthCheck], ctx: AuthContext, ) -> bool: - """Run auth checks with AND logic, stopping at the first failure.""" + """Run auth checks with AND logic.""" check_list = [checks] if not isinstance(checks, list) else checks check_list = cast(list[AuthCheck], check_list) for check in check_list: - if not await _evaluate_check(check, ctx): + try: + result = check(ctx) + if inspect.isawaitable(result): + result = await result + if not result: + return False + except AuthorizationError: + raise + except Exception: + logger.warning( + f"Auth check {getattr(check, '__name__', repr(check))} " + "raised an unexpected exception", + exc_info=True, + ) return False return True diff --git a/fastmcp_slim/fastmcp/utilities/components.py b/fastmcp_slim/fastmcp/utilities/components.py index b59ac9b73..0fc8ea4bd 100644 --- a/fastmcp_slim/fastmcp/utilities/components.py +++ b/fastmcp_slim/fastmcp/utilities/components.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Annotated, Any, ClassVar, TypedDict, cast +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypedDict, cast from mcp_types import Icon from pydantic import BeforeValidator, Field @@ -10,6 +10,10 @@ from typing_extensions import Self, TypeVar from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import FastMCPBaseModel +if TYPE_CHECKING: + from docket import Docket + from docket.execution import Execution + T = TypeVar("T", default=Any) @@ -114,11 +118,7 @@ class FastMCPComponent(FastMCPBaseModel): ) task_config: Annotated[ TaskConfig, - Field( - description="Background task execution configuration (SEP-2663). " - "Only tools support task execution; other component types always " - "carry the default 'forbidden' config." - ), + Field(description="Background task execution configuration (SEP-1686)."), ] = Field(default_factory=lambda: TaskConfig(mode="forbidden")) @classmethod @@ -224,6 +224,56 @@ class FastMCPComponent(FastMCPBaseModel): """Create a copy of the component.""" return self.model_copy() + def register_with_docket(self, docket: Docket) -> None: + """Register this component with docket for background execution. + + No-ops if task_config.mode is "forbidden". Subclasses override to + register their callable (self.run, self.read, self.render, or self.fn). + """ + # Base implementation: no-op (subclasses override) + + def coerce_task_arguments( + self, arguments: dict[str, Any], *, strict: bool = False + ) -> dict[str, Any]: + """Validate and coerce task arguments before any task state is created. + + Called by ``submit_to_docket`` up front, so invalid inputs raise before + the task's Redis metadata and initial status notification exist — + otherwise a coercion failure during queueing would orphan a task the + client has already observed. The base implementation is a no-op; + components that splat arguments into a typed Python callable (e.g. + ``FunctionTool``) override this to mirror the synchronous validation + path. + + When ``strict`` is set (server-level ``strict_input_validation``), + overrides validate in strict mode so the task path rejects lax + coercions (e.g. the string ``"1"`` into an ``int``) exactly as the + synchronous call path does. + """ + return arguments + + async def add_to_docket( + self, docket: Docket, *args: Any, **kwargs: Any + ) -> Execution: + """Schedule this component for background execution via docket. + + Subclasses override this to handle their specific calling conventions: + - Tool: add_to_docket(docket, arguments: dict, **kwargs) + - Resource: add_to_docket(docket, **kwargs) + - ResourceTemplate: add_to_docket(docket, params: dict, **kwargs) + - Prompt: add_to_docket(docket, arguments: dict | None, **kwargs) + + The **kwargs are passed through to docket.add() (e.g., key=task_key). + """ + if not self.task_config.supports_tasks(): + raise RuntimeError( + f"Cannot add {self.__class__.__name__} '{self.name}' to docket: " + f"task execution not supported" + ) + raise NotImplementedError( + f"{self.__class__.__name__} does not implement add_to_docket()" + ) + def get_span_attributes(self) -> dict[str, Any]: """Return span attributes for telemetry. diff --git a/fastmcp_slim/fastmcp/utilities/docstring_parsing.py b/fastmcp_slim/fastmcp/utilities/docstring_parsing.py index 111f37657..babcb1e96 100644 --- a/fastmcp_slim/fastmcp/utilities/docstring_parsing.py +++ b/fastmcp_slim/fastmcp/utilities/docstring_parsing.py @@ -14,6 +14,8 @@ from collections.abc import Callable from dataclasses import dataclass, field from typing import Any +from griffe import Docstring, DocstringSectionKind + _PARSERS = ("google", "numpy", "sphinx") logger = logging.getLogger("griffe") @@ -41,10 +43,6 @@ def parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring: if not doc: return ParsedDocstring() - # Griffe is only needed for functions that actually have docstrings. This - # keeps its parser and model graph out of ordinary server startup. - from griffe import Docstring, DocstringSectionKind - # Try each parser and use the first one that finds parameters. for parser in _PARSERS: docstring = Docstring(doc, lineno=1, parser=parser) diff --git a/fastmcp_slim/fastmcp/utilities/exceptions.py b/fastmcp_slim/fastmcp/utilities/exceptions.py index 97cea8f29..f9166a2b6 100644 --- a/fastmcp_slim/fastmcp/utilities/exceptions.py +++ b/fastmcp_slim/fastmcp/utilities/exceptions.py @@ -7,42 +7,30 @@ from mcp import MCPError import fastmcp +# FastMCP uses httpx2 internally, but user-supplied code (tools, resources, and +# clients handed to the OpenAPI integration) may still raise exceptions from the +# legacy httpx package. These catch tuples include both families when httpx is +# installed, so user errors keep their specific handling without making httpx a +# FastMCP dependency. The two libraries' exception hierarchies match name-for-name. +try: + import httpx -def _is_legacy_httpx_exception(exc: BaseException, exception_type: str) -> bool: - """Check a legacy-httpx exception without importing the legacy package.""" - return any( - cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == exception_type - for cls in type(exc).__mro__ + HTTP_STATUS_ERRORS: tuple[type[BaseException], ...] = ( + httpx2.HTTPStatusError, + httpx.HTTPStatusError, ) - - -def is_http_status_error(exc: BaseException) -> bool: - """Return whether an exception is an httpx2 or legacy-httpx status error.""" - return isinstance(exc, httpx2.HTTPStatusError) or _is_legacy_httpx_exception( - exc, "HTTPStatusError" + TIMEOUT_ERRORS: tuple[type[BaseException], ...] = ( + httpx2.TimeoutException, + httpx.TimeoutException, ) - - -def get_http_status_code(exc: BaseException) -> int | None: - """Return the response status code from a recognized HTTP status error.""" - if not is_http_status_error(exc): - return None - status_code = getattr(getattr(exc, "response", None), "status_code", None) - return status_code if isinstance(status_code, int) else None - - -def is_timeout_error(exc: BaseException) -> bool: - """Return whether an exception is an httpx2 or legacy-httpx timeout.""" - return isinstance(exc, httpx2.TimeoutException) or _is_legacy_httpx_exception( - exc, "TimeoutException" - ) - - -def is_request_error(exc: BaseException) -> bool: - """Return whether an exception is an httpx2 or legacy-httpx request error.""" - return isinstance(exc, httpx2.RequestError) or _is_legacy_httpx_exception( - exc, "RequestError" + REQUEST_ERRORS: tuple[type[BaseException], ...] = ( + httpx2.RequestError, + httpx.RequestError, ) +except ImportError: + HTTP_STATUS_ERRORS = (httpx2.HTTPStatusError,) + TIMEOUT_ERRORS = (httpx2.TimeoutException,) + REQUEST_ERRORS = (httpx2.RequestError,) def iter_exc(group: BaseExceptionGroup): diff --git a/fastmcp_slim/fastmcp/utilities/inspect.py b/fastmcp_slim/fastmcp/utilities/inspect.py index 2348f3410..b9e3d0827 100644 --- a/fastmcp_slim/fastmcp/utilities/inspect.py +++ b/fastmcp_slim/fastmcp/utilities/inspect.py @@ -257,9 +257,8 @@ async def inspect_fastmcp_v1(mcp: SDKServer) -> FastMCPInfo: Returns: FastMCPInfo dataclass containing the extracted information """ - # Inspection reads the full server_info (icons, website_url) that only the - # legacy initialize handshake carries, so pin the handshake era. - async with Client(mcp, mode="legacy") as client: + # Use a client to interact with the SDK's high-level MCPServer + async with Client(mcp) as client: # Get components via client calls (these return MCP objects) mcp_tools = await client.list_tools() mcp_prompts = await client.list_prompts() @@ -391,12 +390,10 @@ async def inspect_fastmcp_v1(mcp: SDKServer) -> FastMCPInfo: # SDK v2's MCPServer (FastMCP 1.x) exposes name/instructions/version # directly; the v1 `_mcp_server` low-level wrapper attribute is gone. - # It defaults `version` to an empty string rather than None, so report - # an unset version as absent instead of blank. return FastMCPInfo( name=mcp.name, instructions=mcp.instructions, - version=mcp.version or None, + version=mcp.version, website_url=server_website_url, icons=server_icons, fastmcp_version=fastmcp.__version__, # Version generating this manifest @@ -470,9 +467,7 @@ async def format_mcp_info(mcp: FastMCP[Any] | SDKServer) -> bytes: Uses Client to get the standard MCP protocol format with camelCase fields. Includes version metadata at the top level. """ - # Inspection reads the full server_info that only the legacy initialize - # handshake carries, so pin the handshake era. - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Get all the MCP protocol objects tools_result = await client.list_tools_mcp() prompts_result = await client.list_prompts_mcp() diff --git a/fastmcp_slim/fastmcp/utilities/json_schema.py b/fastmcp_slim/fastmcp/utilities/json_schema.py index 533a4a7bf..f8996c2f4 100644 --- a/fastmcp_slim/fastmcp/utilities/json_schema.py +++ b/fastmcp_slim/fastmcp/utilities/json_schema.py @@ -1,48 +1,10 @@ from __future__ import annotations +import copy from collections import defaultdict from typing import Any - -def replace_refs(*args: Any, **kwargs: Any) -> Any: - """Call jsonref lazily while preserving the module's patchable boundary.""" - from jsonref import replace_refs as _replace_refs - - return _replace_refs(*args, **kwargs) - - -def _copy_schema(schema: dict[str, Any]) -> dict[str, Any]: - """Return a deep copy of a JSON schema without recursing. - - `copy.deepcopy` consumes stack frames in proportion to nesting depth, so a - deeply nested schema raises `RecursionError` before the traversals in this - module can apply their own depth guards — turning a schema that used to - compress into one that fails outright. Schemas are plain JSON, so an - explicit stack copies the containers at any depth and shares the immutable - scalars at the leaves. - """ - root: dict[str, Any] = {} - stack: list[tuple[Any, Any]] = [(schema, root)] - - while stack: - source, target = stack.pop() - if isinstance(source, dict): - pairs: list[tuple[Any, Any]] = list(source.items()) - else: - pairs = list(enumerate(source)) - - for key, value in pairs: - if isinstance(value, dict): - child: Any = {} - stack.append((value, child)) - elif isinstance(value, list): - child = [None] * len(value) - stack.append((value, child)) - else: - child = value - target[key] = child - - return root +from jsonref import JsonRefError, replace_refs def _defs_have_cycles(defs: dict[str, Any]) -> bool: @@ -226,10 +188,6 @@ def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: if _defs_have_cycles(schema.get("$defs", {})): return resolve_root_ref(schema) - # Most schema operations do not dereference. Keep jsonref (and its requests - # dependency tree) out of server startup until a schema actually needs it. - from jsonref import JsonRefError - try: # Use jsonref to resolve all $ref references # proxies=False returns plain dicts (not proxy objects) @@ -390,7 +348,7 @@ def _prune_param(schema: dict[str, Any], param: str) -> dict[str, Any]: """Return a new schema with *param* removed from `properties`, `required`, and (if no longer referenced) `$defs`. """ - schema = _copy_schema(schema) + schema = copy.deepcopy(schema) # ── 1. drop from properties/required ────────────────────────────── props = schema.get("properties", {}) @@ -543,7 +501,7 @@ def _single_pass_optimize( # Work on a copy so the caller's schema is never mutated (see docstring). The # pruning phases below pop keys/$defs in place, which would otherwise corrupt a # shared dict such as a live Tool.input_schema passed straight to compress_schema. - schema = _copy_schema(schema) + schema = copy.deepcopy(schema) # Phase 1: Collect references and apply simple cleanups # Track which $defs are referenced from the main schema and from other $defs @@ -553,11 +511,6 @@ def _single_pass_optimize( ) # def A references def B defs = schema.get("$defs") - # Set when the traversal below gives up at its depth limit. Once that - # happens the reference scan is incomplete, so we can no longer tell which - # definitions are genuinely unused. - reference_scan_truncated = False - def traverse_and_clean( node: object, current_def_name: str | None = None, @@ -575,10 +528,7 @@ def _single_pass_optimize( about) but we skip all cleanups so we don't mutate user data that happens to look metadata-shaped. """ - nonlocal reference_scan_truncated - if depth > 50: # Prevent infinite recursion - reference_scan_truncated = True return if isinstance(node, dict): @@ -702,13 +652,6 @@ def _single_pass_optimize( for def_name, def_schema in defs.items(): traverse_and_clean(def_schema, current_def_name=def_name, in_schema=True) - # An incomplete scan has not seen every $ref, so a definition that looks - # unused may simply be referenced below the cutoff. Keeping an unused - # definition is harmless; dropping a referenced one leaves a dangling - # $ref and an invalid schema. - if reference_scan_truncated: - return schema - # Phase 4: Remove unused definitions def is_def_used(def_name: str, visiting: set[str] | None = None) -> bool: """Check if a definition is used, handling circular references.""" diff --git a/fastmcp_slim/fastmcp/utilities/logging.py b/fastmcp_slim/fastmcp/utilities/logging.py index eebf4eda8..d39d6ef4a 100644 --- a/fastmcp_slim/fastmcp/utilities/logging.py +++ b/fastmcp_slim/fastmcp/utilities/logging.py @@ -1,9 +1,7 @@ """Logging utilities for FastMCP.""" import contextlib -import importlib.util import logging -from pathlib import Path from typing import Any, Literal, cast from rich.console import Console @@ -13,17 +11,6 @@ from typing_extensions import override import fastmcp -def _get_package_path(package: str) -> str | None: - """Return a package directory without importing the package.""" - try: - spec = importlib.util.find_spec(package) - except ImportError: - return None - if spec is None or spec.origin is None: - return None - return str(Path(spec.origin).parent) - - def get_logger(name: str) -> logging.Logger: """Get a logger nested under FastMCP namespace. @@ -96,11 +83,14 @@ def configure_logging( # no path or level name to maximize width available for the traceback # suppress framework frames and limit the number of frames to 3 - tracebacks_suppress = [ - package_path - for package in ("fastmcp", "mcp", "pydantic") - if (package_path := _get_package_path(package)) is not None - ] + import pydantic + + try: + import mcp + except ImportError: + tracebacks_suppress = [fastmcp, pydantic] + else: + tracebacks_suppress = [fastmcp, mcp, pydantic] # Build traceback kwargs with defaults that can be overridden traceback_kwargs = { diff --git a/fastmcp_slim/fastmcp/utilities/openapi/README.md b/fastmcp_slim/fastmcp/utilities/openapi/README.md index c5e478e19..2f2a5f45f 100644 --- a/fastmcp_slim/fastmcp/utilities/openapi/README.md +++ b/fastmcp_slim/fastmcp/utilities/openapi/README.md @@ -47,7 +47,7 @@ OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDire ### Request Processing ``` -MCP Tool Call → RequestDirector.build() → httpx2.Request → HTTP Response → Structured Output +MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response → Structured Output ``` 1. **Tool Invocation**: FastMCP receives tool call with parameters @@ -103,14 +103,14 @@ All components use the same RequestDirector approach: ### Basic Server Setup ```python -import httpx2 +import httpx from fastmcp.server.openapi import FastMCPOpenAPI # OpenAPI spec (can be loaded from file/URL) openapi_spec = {...} # Create HTTP client -async with httpx2.AsyncClient() as client: +async with httpx.AsyncClient() as client: # Create server with stateless request building server = FastMCPOpenAPI( openapi_spec=openapi_spec, @@ -134,8 +134,8 @@ director = RequestDirector(spec) # Build HTTP request request = director.build(route, flat_arguments, base_url) -# Execute with httpx2 -async with httpx2.AsyncClient() as client: +# Execute with httpx +async with httpx.AsyncClient() as client: response = await client.send(request) ``` @@ -206,6 +206,6 @@ Tests are located in `/tests/server/openapi/`: ## Dependencies - `openapi-core` - OpenAPI specification processing and validation -- `httpx2` - HTTP client library +- `httpx` - HTTP client library - `pydantic` - Data validation and serialization -- `urllib.parse` - URL building and manipulation +- `urllib.parse` - URL building and manipulation \ No newline at end of file diff --git a/fastmcp_slim/fastmcp/utilities/openapi/parser.py b/fastmcp_slim/fastmcp/utilities/openapi/parser.py index e492a4c5a..f83a45249 100644 --- a/fastmcp_slim/fastmcp/utilities/openapi/parser.py +++ b/fastmcp_slim/fastmcp/utilities/openapi/parser.py @@ -1,6 +1,6 @@ """OpenAPI parsing logic for converting OpenAPI specs to HTTPRoute objects.""" -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, cast from openapi_pydantic import ( OpenAPI, @@ -36,7 +36,6 @@ from .models import ( ) from .schemas import ( _combine_schemas_and_map_params, - _discriminator_target_name, _replace_ref_with_defs, ) @@ -146,16 +145,10 @@ class OpenAPIParser( def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation: """Convert string parameter location to our ParameterLocation type.""" - locations: dict[str, ParameterLocation] = { - "path": "path", - "query": "query", - "header": "header", - "cookie": "cookie", - } - if location := locations.get(param_in): - return location + if param_in in ["path", "query", "header", "cookie"]: + return cast(ParameterLocation, param_in) logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'") - return "query" + return cast(ParameterLocation, "query") def _resolve_ref(self, item: Any) -> Any: """Resolves a reference to its target definition.""" @@ -544,7 +537,6 @@ class OpenAPIParser( schema: dict, all_schemas: dict[str, Any], collected: set[str] | None = None, - follow_discriminator: bool = False, ) -> set[str]: """ Extract all schema names referenced by a schema (including transitive dependencies). @@ -553,10 +545,6 @@ class OpenAPIParser( schema: The schema to analyze all_schemas: All available schema definitions collected: Set of already collected schema names (for recursion) - follow_discriminator: Also collect the subtypes named by a - `discriminator.mapping`. Those values are bare strings rather - than `$ref` objects, so they are invisible to ordinary ref - collection. Returns: Set of schema names that are referenced @@ -564,12 +552,6 @@ class OpenAPIParser( if collected is None: collected = set() - def collect(schema_name: str) -> None: - """Collect a schema by name and recurse into its dependencies.""" - if schema_name not in collected and schema_name in all_schemas: - collected.add(schema_name) - find_refs(all_schemas[schema_name]) - def find_refs(obj): """Recursively find all $ref references.""" if isinstance(obj, dict): @@ -582,19 +564,14 @@ class OpenAPIParser( return # Add this schema and recursively find its dependencies - collect(schema_name) - - if follow_discriminator: - discriminator = obj.get("discriminator") - if isinstance(discriminator, dict): - mapping = discriminator.get("mapping") - if isinstance(mapping, dict): - for target in mapping.values(): - if not isinstance(target, str): - continue - name = _discriminator_target_name(target) - if name: - collect(name) + if ( + collected is not None + and schema_name not in collected + and schema_name in all_schemas + ): + collected.add(schema_name) + # Recursively find dependencies of this schema + find_refs(all_schemas[schema_name]) # Continue searching in all values for value in obj.values(): @@ -631,15 +608,10 @@ class OpenAPIParser( deps = self._extract_schema_dependencies(param.schema_, all_schemas) needed_schemas.update(deps) - # Check request body for schema references. Request bodies are flattened - # into a single object, so discriminated subtypes need to come along. + # Check request body for schema references if request_body and request_body.content_schema: for content_schema in request_body.content_schema.values(): - deps = self._extract_schema_dependencies( - content_schema, - all_schemas, - follow_discriminator=True, - ) + deps = self._extract_schema_dependencies(content_schema, all_schemas) needed_schemas.update(deps) # Return only the needed input schemas diff --git a/fastmcp_slim/fastmcp/utilities/openapi/schemas.py b/fastmcp_slim/fastmcp/utilities/openapi/schemas.py index c8fe95b9b..5980621c2 100644 --- a/fastmcp_slim/fastmcp/utilities/openapi/schemas.py +++ b/fastmcp_slim/fastmcp/utilities/openapi/schemas.py @@ -221,155 +221,6 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]: return schema -def _allof_members( - schema: dict[str, Any], - schema_defs: dict[str, Any], - resolving: set[str] | None = None, -) -> list[dict[str, Any]]: - """Expand local schema references while collecting ``allOf`` members.""" - resolving = resolving or set() - - ref = schema.get("$ref") - if isinstance(ref, str): - for prefix in ("#/$defs/", "#/components/schemas/"): - if ref.startswith(prefix): - name = ref.removeprefix(prefix) - referenced_schema = schema_defs.get(name) - if isinstance(referenced_schema, dict) and name not in resolving: - siblings = { - key: value for key, value in schema.items() if key != "$ref" - } - members = _allof_members( - referenced_schema, schema_defs, resolving | {name} - ) - return members + ([siblings] if siblings else []) - break - - all_of = schema.get("allOf") - if isinstance(all_of, list): - members = [] - for member in all_of: - if isinstance(member, dict): - members.extend(_allof_members(member, schema_defs, resolving)) - - siblings = {key: value for key, value in schema.items() if key != "allOf"} - return members + ([siblings] if siblings else []) - - return [schema] - - -def _discriminator_target_name(target: str) -> str | None: - """Resolve a ``discriminator.mapping`` value to a local schema name. - - Mapping values hold "schema names or references", so a bare ``"Cat"`` means - the ``Cat`` component just as ``"#/components/schemas/Cat"`` does. Anything - else — a remote URL, a pointer outside the component schemas — has no local - definition to flatten. - """ - for prefix in ("#/$defs/", "#/components/schemas/"): - if target.startswith(prefix): - return target.removeprefix(prefix) or None - if target.startswith("#") or "/" in target: - return None - return target or None - - -def _flatten_discriminator_subtypes( - schema: dict[str, Any], - schema_defs: dict[str, Any], -) -> dict[str, Any] | None: - """Flatten the subtypes named by an OpenAPI ``discriminator.mapping``. - - A parent schema carrying a discriminator describes its children only - through ``mapping``, so the child fields are unreachable from the parent's - own ``properties``. Rather than emitting a branch per subtype, the fields - are merged in as optional and the variants are spelled out on the - discriminator property's description. Top-level ``oneOf`` is filled in - poorly by LLM tool-calling APIs, and the upstream API remains the real - validator either way: a field from the wrong variant is rejected there - rather than locally. - - Only the mapping on *schema* itself is expanded. A subtype carrying its own - discriminator is left alone, which also keeps the parent/child reference - cycle from recursing. - - Returns replacement ``properties`` for *schema*, or None when there is no - usable discriminator mapping to flatten. - """ - discriminator = schema.get("discriminator") - if not isinstance(discriminator, dict): - return None - - property_name = discriminator.get("propertyName") - mapping = discriminator.get("mapping") - if not isinstance(property_name, str) or not isinstance(mapping, dict): - return None - - own_props = schema.get("properties", {}) - # Variants that disagree about a property are unioned rather than resolved: - # keeping whichever came first would advertise one variant's constraint - # (a `const` tag, say) while claiming to accept all of them. - alternatives: dict[str, list[Any]] = {} - values: list[str] = [] - variants: list[str] = [] - - for value, target in mapping.items(): - if not isinstance(target, str): - continue - - name = _discriminator_target_name(target) - subtype = schema_defs.get(name) if name else None - if not isinstance(subtype, dict): - continue - - # Fields the parent already declares are shared, not variant-specific. - variant_fields: list[str] = [] - for member in _allof_members(subtype, schema_defs): - for prop_name, prop_schema in member.get("properties", {}).items(): - if prop_name in own_props: - continue - if prop_name not in variant_fields: - variant_fields.append(prop_name) - seen = alternatives.setdefault(prop_name, []) - if prop_schema not in seen: - seen.append(prop_schema) - - values.append(repr(value)) - if variant_fields: - variants.append(f"{value!r} uses {', '.join(variant_fields)}") - - # Every resolved variant is a legal tag even when it adds no fields of its - # own, so the accepted values are worth advertising on their own. - if not values: - return None - - subtype_props = { - prop_name: schemas[0] if len(schemas) == 1 else {"anyOf": schemas} - for prop_name, schemas in alternatives.items() - } - properties = {**own_props, **subtype_props} - - note = f"Selects the variant. Accepted values: {', '.join(values)}." - if variants: - note += ( - f" {'; '.join(variants)}." - " Send only the fields belonging to the selected variant." - ) - - # A discriminator names a property of the payload, so give it a schema even - # when the parent left it undeclared — it is otherwise required and unusable. - tag_schema = properties.get(property_name) - if not isinstance(tag_schema, dict): - tag_schema = {"type": "string"} - existing = tag_schema.get("description") - properties[property_name] = { - **tag_schema, - "description": f"{existing} {note}" if existing else note, - } - - return properties - - def _combine_schemas_and_map_params( route: HTTPRoute, convert_refs: bool = True, @@ -422,13 +273,14 @@ def _combine_schemas_and_map_params( merged_props = {} merged_required = [] - for sub_schema in _allof_members(body_schema, route.request_schemas): - # Merge properties - if "properties" in sub_schema: - merged_props.update(sub_schema["properties"]) - # Merge required fields - if "required" in sub_schema: - merged_required.extend(sub_schema["required"]) + for sub_schema in body_schema["allOf"]: + if isinstance(sub_schema, dict): + # Merge properties + if "properties" in sub_schema: + merged_props.update(sub_schema["properties"]) + # Merge required fields + if "required" in sub_schema: + merged_required.extend(sub_schema["required"]) # Update body_schema with merged properties body_schema["properties"] = merged_props @@ -441,17 +293,6 @@ def _combine_schemas_and_map_params( # Remove the allOf since we've merged it body_schema.pop("allOf", None) - # Merge discriminated subtype fields in as optional. The discriminator - # itself is dropped: its mapping points at definitions that are pruned - # from $defs once nothing references them, which would leave the - # emitted schema with dangling refs. - flattened_props = _flatten_discriminator_subtypes( - body_schema, route.request_schemas - ) - if flattened_props is not None: - body_schema["properties"] = flattened_props - body_schema.pop("discriminator", None) - body_props = body_schema.get("properties", {}) # Detect collisions: parameters that exist in multiple non-body locations diff --git a/fastmcp_slim/fastmcp/utilities/prefab.py b/fastmcp_slim/fastmcp/utilities/prefab.py deleted file mode 100644 index 805a5fede..000000000 --- a/fastmcp_slim/fastmcp/utilities/prefab.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Lazy helpers for FastMCP's optional Prefab UI integration.""" - -from __future__ import annotations - -import sys -from functools import lru_cache -from importlib.util import find_spec -from typing import Any - - -@lru_cache(maxsize=1) -def prefab_available() -> bool: - """Return whether Prefab UI is installed without importing it.""" - return find_spec("prefab_ui") is not None - - -@lru_cache(maxsize=1) -def _get_prefab_types() -> tuple[type[Any], type[Any]] | None: - """Import and return Prefab's public app and component types on demand.""" - if not prefab_available(): - return None - - from prefab_ui.app import PrefabApp - from prefab_ui.components.base import Component - - return PrefabApp, Component - - -def _could_be_prefab(value_or_type: Any) -> bool: - """Cheaply reject ordinary values before importing Prefab UI.""" - candidate_type = ( - value_or_type if isinstance(value_or_type, type) else type(value_or_type) - ) - module = getattr(candidate_type, "__module__", "") - return ( - "prefab_ui" in sys.modules - or module == "prefab_ui" - or module.startswith("prefab_ui.") - ) - - -def is_prefab_type(candidate: Any) -> bool: - """Return whether a type is a Prefab app or component type.""" - if not isinstance(candidate, type) or not _could_be_prefab(candidate): - return False - - prefab_types = _get_prefab_types() - return prefab_types is not None and issubclass(candidate, prefab_types) - - -def is_prefab_app(value: Any) -> bool: - """Return whether a value is a Prefab app.""" - if not _could_be_prefab(value): - return False - - prefab_types = _get_prefab_types() - return prefab_types is not None and isinstance(value, prefab_types[0]) - - -def is_prefab_component(value: Any) -> bool: - """Return whether a value is a Prefab component.""" - if not _could_be_prefab(value): - return False - - prefab_types = _get_prefab_types() - return prefab_types is not None and isinstance(value, prefab_types[1]) - - -def prefab_app_from_component(component: Any) -> Any: - """Wrap a Prefab component in a Prefab app.""" - prefab_types = _get_prefab_types() - if prefab_types is None or not isinstance(component, prefab_types[1]): - raise TypeError("Expected a Prefab UI component") - return prefab_types[0](view=component) diff --git a/fastmcp_slim/fastmcp/utilities/skills.py b/fastmcp_slim/fastmcp/utilities/skills.py index 73b859548..2c93b1f7f 100644 --- a/fastmcp_slim/fastmcp/utilities/skills.py +++ b/fastmcp_slim/fastmcp/utilities/skills.py @@ -205,7 +205,7 @@ async def download_skill( # Write content if isinstance(content, mcp_types.TextResourceContents): - file_path.write_text(content.text, encoding="utf-8") + file_path.write_text(content.text) elif isinstance(content, mcp_types.BlobResourceContents): file_path.write_bytes(base64.b64decode(content.blob)) else: diff --git a/fastmcp_slim/fastmcp/utilities/tasks.py b/fastmcp_slim/fastmcp/utilities/tasks.py index b6cd44e84..0886dacbb 100644 --- a/fastmcp_slim/fastmcp/utilities/tasks.py +++ b/fastmcp_slim/fastmcp/utilities/tasks.py @@ -13,13 +13,6 @@ from fastmcp.utilities.async_utils import is_coroutine_function TaskMode = Literal["forbidden", "optional", "required"] -#: Reverse-DNS identifier of the SEP-2663 tasks extension. A tool declared with -#: ``task=True`` requires an extension with this identifier to be registered on -#: the server (``mcp.add_extension(...)``); the ``fastmcp-tasks`` package -#: provides it. Kept here as pure declaration so core can check for the -#: extension without importing the tasks package. -TASKS_EXTENSION_ID = "io.modelcontextprotocol/tasks" - DEFAULT_POLL_INTERVAL = timedelta(seconds=5) DEFAULT_POLL_INTERVAL_MS = int(DEFAULT_POLL_INTERVAL.total_seconds() * 1000) DEFAULT_TTL_MS = 60_000 @@ -66,6 +59,10 @@ class TaskConfig: if not self.supports_tasks(): return + from fastmcp.server.dependencies import require_docket + + require_docket(f"`task=True` on function '{name}'") + fn_to_check = fn if ( not inspect.isroutine(fn) diff --git a/fastmcp_slim/fastmcp/utilities/tests.py b/fastmcp_slim/fastmcp/utilities/tests.py index aad3176e9..77b31ba36 100644 --- a/fastmcp_slim/fastmcp/utilities/tests.py +++ b/fastmcp_slim/fastmcp/utilities/tests.py @@ -1,13 +1,11 @@ from __future__ import annotations -import asyncio import copy import multiprocessing import socket import time from collections.abc import AsyncGenerator, Callable, Generator from contextlib import asynccontextmanager, contextmanager, suppress -from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal from urllib.parse import parse_qs, urlparse @@ -17,18 +15,9 @@ from mcp.shared.auth import AuthorizationCodeResult from fastmcp import settings from fastmcp.client.auth.oauth import OAuth -from fastmcp.client.client import Client -from fastmcp.client.transports.http import StreamableHttpTransport -from fastmcp.client.transports.sse import SSETransport -from fastmcp.utilities.asgi_transport import ( - StreamingASGITransport, - run_asgi_lifespan, -) from fastmcp.utilities.http import find_available_port if TYPE_CHECKING: - from starlette.types import ASGIApp - from fastmcp.server.server import FastMCP @@ -151,26 +140,6 @@ def run_server_in_process( raise RuntimeError("Server process failed to terminate even after kill") -async def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None: - """Poll until a TCP connection to `host:port` is accepted, or raise on timeout.""" - deadline = time.monotonic() + timeout - while True: - try: - _, writer = await asyncio.open_connection(host, port) - except (ConnectionRefusedError, OSError): - if time.monotonic() >= deadline: - raise RuntimeError( - f"Server did not start listening on {host}:{port} " - f"within {timeout} seconds" - ) from None - await asyncio.sleep(0.001) - else: - writer.close() - with suppress(ConnectionResetError, BrokenPipeError): - await writer.wait_closed() - return - - @asynccontextmanager async def run_server_async( server: FastMCP, @@ -180,13 +149,11 @@ async def run_server_async( host: str = "127.0.0.1", ) -> AsyncGenerator[str, None]: """ - Start a FastMCP server on a real port as an asyncio task. + Start a FastMCP server as an asyncio task for in-process async testing. - This runs a real uvicorn server in the current process, bound to a real TCP port, - and yields its URL. Use it when the behaviour under test is genuinely about the - network — real sockets, TLS, or a server that must be reachable by something other - than an in-process client. Otherwise prefer `asgi_client` or `asgi_server`, which - exercise the same HTTP stack without binding a port. + This is the recommended way to test FastMCP servers. It runs the server + as an async task in the same process, eliminating subprocess coordination, + sleeps, and cleanup issues. Args: server: FastMCP server instance @@ -222,9 +189,14 @@ async def run_server_async( assert result.content[0].text == "Hello, World!" ``` """ + import asyncio + if port is None: port = find_available_port() + # Wait a tiny bit for the port to be released if it was just used + await asyncio.sleep(0.01) + # Start server as a background task server_task = asyncio.create_task( server.run_http_async( @@ -239,9 +211,8 @@ async def run_server_async( # Wait for server lifespan to be ready await server._started.wait() - # The lifespan completing does not guarantee uvicorn has bound the port yet, so - # poll until the socket accepts a connection rather than guessing at a sleep. - await _wait_for_port(host, port) + # Give uvicorn a moment to bind the port after lifespan is ready + await asyncio.sleep(0.1) try: yield f"http://{host}:{port}{path}" @@ -252,215 +223,6 @@ async def run_server_async( await asyncio.wait_for(server_task, timeout=2.0) -@dataclass(frozen=True) -class ASGIServer: - """A FastMCP server's real HTTP app, reachable in-process with no sockets. - - Yielded by `asgi_server`. The `url` looks like an ordinary server URL and the app - behind it is the genuine article — auth middleware, session manager, SSE framing and - redirects all run — but every request is dispatched straight into the ASGI - application on the current event loop. - - Because nothing is listening on the network, a plain `httpx2.AsyncClient()` cannot - reach this server. Use `client()` for a FastMCP client, `http_client()` for raw HTTP - assertions, and `transport()` when you need to build the client transport yourself. - """ - - url: str - app: ASGIApp - transport_type: Literal["http", "streamable-http", "sse"] - - def http_client( - self, - headers: dict[str, str] | None = None, - timeout: httpx2.Timeout | None = None, - auth: httpx2.Auth | None = None, - **kwargs: Any, - ) -> httpx2.AsyncClient: - """An `httpx2.AsyncClient` bound to the in-process app, for raw HTTP assertions. - - Relative URLs resolve against the server's base URL, and absolute URLs on the - same origin work too, so `client.get(f"{server.url}/health")` reads the same as - it would against a real server. - - The signature matches `McpHttpClientFactory`, so this method can also be handed - to anything that takes an `httpx_client_factory`. - """ - # The legacy SSE transport runs the whole MCP session inside its GET request and - # only releases its streams once that request observes a disconnect, so the - # bridge must let the application drain rather than cancelling at close. - cancel_on_close = self.transport_type != "sse" - return httpx2.AsyncClient( - transport=StreamingASGITransport(self.app, cancel_on_close=cancel_on_close), - base_url=self.url, - headers=headers, - timeout=timeout, - auth=auth, - **kwargs, - ) - - def transport(self, **kwargs: Any) -> StreamableHttpTransport | SSETransport: - """A FastMCP client transport wired to the in-process app. - - Accepts the same keyword arguments as the underlying transport (`headers`, - `auth`, ...); `httpx_client_factory` is supplied automatically. - """ - kwargs.setdefault("httpx_client_factory", self.http_client) - if self.transport_type == "sse": - return SSETransport(self.url, **kwargs) - return StreamableHttpTransport(self.url, **kwargs) - - def client( - self, - *, - headers: dict[str, str] | None = None, - auth: httpx2.Auth | Literal["oauth"] | str | None = None, - **client_kwargs: Any, - ) -> Client: - """An unconnected FastMCP `Client` pointed at the in-process app. - - `headers` and `auth` configure the underlying HTTP transport; every other - keyword argument is passed to `Client` (`timeout`, `elicitation_handler`, ...). - Use it as a context manager, exactly like any other client. - - Args: - headers: HTTP headers to send with every request. - auth: Client authentication, as accepted by the HTTP transports. - **client_kwargs: Additional arguments forwarded to `Client`. - """ - return Client(self.transport(headers=headers, auth=auth), **client_kwargs) - - -@asynccontextmanager -async def asgi_server( - server: FastMCP, - transport: Literal["http", "streamable-http", "sse"] = "http", - path: str | None = None, - **http_app_kwargs: Any, -) -> AsyncGenerator[ASGIServer, None]: - """ - Serve a FastMCP server's HTTP app in-process, with no socket and no uvicorn. - - This is the fastest way to test a FastMCP server over HTTP. The server's real - Starlette app is built with `http_app()` and its lifespan is started, then every - request is dispatched directly into the app on the current event loop. That skips - port binding, uvicorn startup and connection setup entirely, while still exercising - the full HTTP stack: middleware, authentication, session management and SSE - streaming all run exactly as they do in production. - - Use this as a fixture when several tests share one server but each needs its own - client. For a single test, `asgi_client` hands you a connected client in one step. - - Args: - server: FastMCP server instance. - transport: Transport type ("http", "streamable-http", or "sse"). - path: URL path for the server (defaults to "/mcp", or "/sse" for SSE). - **http_app_kwargs: Additional arguments forwarded to `server.http_app()`. - - Yields: - An `ASGIServer` describing how to reach the app. - - Example: - ```python - import pytest - from fastmcp import FastMCP - from fastmcp.utilities.tests import ASGIServer, asgi_server - - mcp = FastMCP("test") - - @mcp.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - @pytest.fixture - async def server(): - async with asgi_server(mcp) as running_server: - yield running_server - - async def test_greet(server: ASGIServer): - async with server.client() as client: - result = await client.call_tool("greet", {"name": "World"}) - assert result.data == "Hello, World!" - - async def test_greet_with_headers(server: ASGIServer): - async with server.client(headers={"X-Tenant": "acme"}) as client: - result = await client.call_tool("greet", {"name": "World"}) - assert result.data == "Hello, World!" - ``` - """ - if path is None: - path = "/sse" if transport == "sse" else "/mcp" - - app = server.http_app(transport=transport, path=path, **http_app_kwargs) - - # Nothing listens on this origin; it exists so that URLs are well-formed and so - # that host-header checks see a loopback address, as they would locally. - base_url = "http://127.0.0.1" - - async with run_asgi_lifespan(app): - yield ASGIServer( - url=f"{base_url}{path}", - app=app, - transport_type=transport, - ) - - -@asynccontextmanager -async def asgi_client( - server: FastMCP, - transport: Literal["http", "streamable-http", "sse"] = "http", - path: str | None = None, - *, - headers: dict[str, str] | None = None, - auth: httpx2.Auth | Literal["oauth"] | str | None = None, - **client_kwargs: Any, -) -> AsyncGenerator[Client, None]: - """ - Serve a FastMCP server over HTTP in-process and yield a connected `Client`. - - This is the shortest path to testing a server over a real HTTP stack. The server's - Starlette app is built and started, and requests are dispatched straight into it on - the current event loop — no port, no uvicorn, no subprocess — but middleware, - authentication, session management and SSE streaming all behave as in production. - - Reach for `asgi_server` instead when a fixture must serve several tests that each - build their own client, or when a test needs raw HTTP access to the app. - - Args: - server: FastMCP server instance. - transport: Transport type ("http", "streamable-http", or "sse"). - path: URL path for the server (defaults to "/mcp", or "/sse" for SSE). - headers: HTTP headers to send with every request. - auth: Client authentication, as accepted by the HTTP transports. - **client_kwargs: Additional arguments forwarded to `Client`. - - Yields: - A connected `Client`. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.utilities.tests import asgi_client - - async def test_greet(): - mcp = FastMCP("test") - - @mcp.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - async with asgi_client(mcp) as client: - result = await client.call_tool("greet", {"name": "World"}) - assert result.data == "Hello, World!" - ``` - """ - async with ( - asgi_server(server, transport=transport, path=path) as running_server, - running_server.client(headers=headers, auth=auth, **client_kwargs) as client, - ): - yield client - - class HeadlessOAuth(OAuth): """ OAuth provider that bypasses browser interaction for testing. @@ -508,7 +270,6 @@ class HeadlessOAuth(OAuth): auth_code = query_params["code"][0] state = query_params.get("state", [None])[0] - iss = query_params.get("iss", [None])[0] - return AuthorizationCodeResult(code=auth_code, state=state, iss=iss) + return AuthorizationCodeResult(code=auth_code, state=state) else: raise RuntimeError(f"Authorization failed: {response.status_code}") diff --git a/fastmcp_slim/fastmcp/utilities/types.py b/fastmcp_slim/fastmcp/utilities/types.py index ea2024d85..fe43c9081 100644 --- a/fastmcp_slim/fastmcp/utilities/types.py +++ b/fastmcp_slim/fastmcp/utilities/types.py @@ -335,21 +335,18 @@ class Audio: def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" - mapping = { - "wav": "audio/wav", - "mp3": "audio/mpeg", - "ogg": "audio/ogg", - "m4a": "audio/mp4", - "flac": "audio/flac", - } - if self._format: - return mapping.get(self._format.lower(), f"audio/{self._format.lower()}") + return f"audio/{self._format.lower()}" if self.path: - return mapping.get( - self.path.suffix.lower().lstrip("."), "application/octet-stream" - ) + suffix = self.path.suffix.lower() + return { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".m4a": "audio/mp4", + ".flac": "audio/flac", + }.get(suffix, "application/octet-stream") return "audio/wav" # default for raw binary data def to_audio_content( @@ -424,12 +421,7 @@ class File: elif self.data is not None: raw_data = self.data if self._name: - extension = ( - "" - if Path(self._name).suffix - else f".{self._mime_type.split('/')[1]}" - ) - uri_str = f"file:///{self._name}{extension}" + uri_str = f"file:///{self._name}.{self._mime_type.split('/')[1]}" else: uri_str = f"file:///resource.{self._mime_type.split('/')[1]}" else: diff --git a/fastmcp_slim/pyproject.toml b/fastmcp_slim/pyproject.toml index 6d56f3e93..bb78b47a2 100644 --- a/fastmcp_slim/pyproject.toml +++ b/fastmcp_slim/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version", "optional-dependencies"] description = "The dependency-slim FastMCP package." authors = [{ name = "Jeremiah Lowin" }] dependencies = [ - "mcp-types>=2.0.0,<3.0.0", + "mcp-types==2.0.0b2", "platformdirs>=4.0.0", "pydantic[email]>=2.12.0", "pydantic-settings>=2.0.0", @@ -74,7 +74,7 @@ client = [ "authlib>=1.6.11", "py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0", ] -code-mode = ["pydantic-monty==0.0.18"] +code-mode = ["pydantic-monty==0.0.17"] gemini = ["google-genai>=1.18.0", "jsonref>=1.1.0"] mcp = [ "exceptiongroup>=1.2.2", @@ -82,7 +82,7 @@ mcp = [ # client auth) requires it, and all FastMCP-owned HTTP (server auth provider # upstream calls, OpenAPI provider, version check, etc.) uses it too. "httpx2>=2.5.0", - "mcp>=2.0.0,<3.0.0", + "mcp==2.0.0b2", "opentelemetry-api>=1.28.0", # starlette floor: transitive via mcp (which only requires >=0.27). # Pin past CVE-2026-48710, which was patched in 1.0.1. @@ -96,7 +96,7 @@ server = [ "griffelib>=2.0.0", "jsonref>=1.1.0", "jsonschema-path>=0.3.4", - "joserfc>=1.5.0", + "joserfc>=1.1.0", "openapi-pydantic>=0.5.1", "packaging>=24.0", "py-key-value-aio[filetree,keyring,memory]>=0.4.4,<0.5.0", @@ -108,3 +108,4 @@ server = [ "watchfiles>=1.0.0", "websockets>=15.0.1", ] +tasks = ["pydocket>=0.20.0"] diff --git a/fastmcp_tasks/README.md b/fastmcp_tasks/README.md deleted file mode 100644 index f8564ea5e..000000000 --- a/fastmcp_tasks/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# fastmcp-tasks - -A complete implementation of background tasks for the Model Context Protocol — the `io.modelcontextprotocol/tasks` extension defined in [SEP-2663](https://github.com/modelcontextprotocol/ext-tasks). - -The MCP tasks extension is a Final SEP, but as of this writing it ships in the ecosystem as a schema and a prose specification — no language SDK provides a working runtime for it. `fastmcp-tasks` is, to our knowledge, the first: a full server-side implementation of the protocol, built on the durable execution engine ([docket](https://github.com/chrisguidry/docket)) that FastMCP has run in production since v3. If you want to actually *run* MCP background tasks today, this is the implementation. - -## What background tasks are - -Most tool calls are synchronous: the client sends `tools/call` and holds the request open until the tool returns. That breaks down for work that takes minutes or hours — a long analysis, a batch job, a slow external API. The tasks extension lets a server answer such a call *immediately* with a durable task handle, then run the work in the background while the client polls for completion on its own schedule. - -The model is poll-based and stateless by construction, which is what makes it survive disconnects, server restarts, and load balancers: - -1. A client that supports tasks issues a normal `tools/call` with a per-request opt-in. -2. The server decides whether to run it as a task. If it does, it returns a `CreateTaskResult` carrying a server-generated task id — right away, before the work starts. -3. The client polls `tasks/get` until the task reaches a terminal state, then reads the result inlined in the response. -4. `tasks/cancel` requests cancellation; `tasks/update` answers any input the task asks for mid-run. - -The server owns the task's durable state, so the client can poll across independent requests — from any process, after a crash, through any replica — with no session affinity required. - -## Usage - -Install it as the `tasks` extra on FastMCP: - -```bash -uv pip install "fastmcp[tasks]" -``` - -Register the extension on your server and mark the tools that may run as tasks. The extension is where the backend is configured — point it at Redis for a distributed deployment, or leave it on the in-memory default for a single process: - -```python -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension - -mcp = FastMCP("Analytics") -mcp.add_extension(TasksExtension(url="redis://localhost:6379/0")) - - -@mcp.tool(task=True) -async def analyze(dataset: str) -> str: - # Long-running work. The client gets a task handle immediately and - # polls for the result; this runs in a background worker. - ... -``` - -`task=True` is a declaration of intent — this tool *may* run as a task — while the server, per the spec, decides per call whether to actually task it. Use `TaskConfig` for finer control: - -```python -from fastmcp.utilities.tasks import TaskConfig - - -@mcp.tool(task=TaskConfig(mode="required")) -async def must_run_async(n: int) -> int: - # Always runs as a task; a client that has not opted in is told so. - ... -``` - -Registering `TasksExtension` is required to serve `task=True` tools — the tool declares intent, the extension provides the engine. A `task=True` tool on a server with no tasks extension registered fails loudly at startup rather than silently running inline. - -### Running out-of-process workers - -For distributed deployments backed by Redis, run dedicated worker processes alongside your server: - -```bash -python -m fastmcp_tasks.worker_cli worker server.py -``` - -Workers and servers that share a backend URL and queue name share a task queue, so you can scale execution independently of your request-serving frontends. - -## Configuration - -The backend is configured on the extension. Every option also has a `FASTMCP_DOCKET_*` environment variable, so an env-configured deployment can construct `TasksExtension()` with no arguments: - -| Option | Env var | Default | Description | -| --- | --- | --- | --- | -| `url` | `FASTMCP_DOCKET_URL` | `memory://` | Backend URL. `memory://` for single-process; `redis://host:port/db` for distributed workers. | -| `name` | `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. | -| `concurrency` | `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. | - -See the [FastMCP task documentation](https://gofastmcp.com/servers/tasks) for the full reference. - -## Status - -The tasks extension is an experimental MCP extension, and `fastmcp-tasks` tracks its draft schema. The protocol's shape is settled — SEP-2663 is Final — but field-level details may still move; this package versions independently so it can follow the schema without waiting on a FastMCP release. diff --git a/fastmcp_tasks/fastmcp_tasks/__init__.py b/fastmcp_tasks/fastmcp_tasks/__init__.py deleted file mode 100644 index 7d6420426..000000000 --- a/fastmcp_tasks/fastmcp_tasks/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Background task execution for FastMCP via the SEP-2663 tasks extension.""" - -from importlib.metadata import PackageNotFoundError, version - -from fastmcp.client.extension_hooks import register_internal_client_extension_factory -from fastmcp_tasks.client import ToolTask, _build_tasks_client_extension, call_tool_task -from fastmcp_tasks.extension import TasksExtension - -try: - __version__ = version("fastmcp-tasks") -except PackageNotFoundError: - __version__ = "0.0.0" - -# Register the client half so every FastMCP `Client` transparently drives a -# task-serving backend's background tasks (see `fastmcp_tasks.client`). Importing -# this package — which any task deployment does, server or client side — is what -# turns on client task support. -register_internal_client_extension_factory(_build_tasks_client_extension) - -__all__ = ["TasksExtension", "ToolTask", "call_tool_task", "__version__"] diff --git a/fastmcp_tasks/fastmcp_tasks/client.py b/fastmcp_tasks/fastmcp_tasks/client.py deleted file mode 100644 index 82c9041d2..000000000 --- a/fastmcp_tasks/fastmcp_tasks/client.py +++ /dev/null @@ -1,567 +0,0 @@ -"""SEP-2663 client task support: the tasks extension, resolver, and handle. - -FastMCP drives a server's background tasks transparently. When a `task=True` -tool runs a call as a task, the server answers `tools/call` with a claimed -`CreateTaskResult` (SEP-2133) instead of the tool's result. This module supplies -the client half: - -- `TasksClientExtension` advertises the tasks capability (so the server *may* - task the call) and declares a `ResultClaim` for `resultType: "task"`. It is - registered on every FastMCP `Client` automatically, so the caller opts in to - nothing. -- The claim's resolver polls `tasks/get` to completion under the hood and returns - the tool's real result as a `CallToolResult` — the caller of `call_tool` never - learns the call was tasked. A task that pauses for input is answered through the - client's `elicitation_handler` via `tasks/update`, then polling resumes. -- `ToolTask` is the explicit handle for callers who want to return immediately and - drive the task themselves (`status`/`wait`/`result`/`cancel`), built via - `call_tool_task`. - -Tasks are modern-protocol only: on a legacy connection the SDK strips the -capability ad, the server never tasks, and this extension is inert. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, cast - -import mcp_types -from mcp.client.extension import ClaimContext, ClientExtension, ResultClaim -from mcp.client.session import ClientRequestContext, ClientSession, ElicitationFnT -from mcp_types import CallToolResult -from mcp_types.version import MODERN_PROTOCOL_VERSIONS - -from fastmcp.client.telemetry import client_span -from fastmcp.exceptions import ToolError -from fastmcp.telemetry import inject_trace_context -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.tasks import TASKS_EXTENSION_ID -from fastmcp.utilities.timeout import normalize_timeout_to_seconds -from fastmcp_tasks.client_models import ( - CancelTaskRequest, - CancelTaskRequestParams, - ClientCreateTaskResult, - ClientGetTaskResult, - GetTaskRequest, - GetTaskRequestParams, - UpdateTaskRequest, - UpdateTaskRequestParams, -) -from fastmcp_tasks.settings import client_settings - -if TYPE_CHECKING: - from fastmcp.client.client import CallToolResult as FastMCPCallToolResult - from fastmcp.client.client import Client - -logger = get_logger(__name__) - -#: Floor for the fallback poll interval (seconds). When the server does not -#: advertise a `pollIntervalMs`, each drive starts its backoff ramp here so quick -#: tasks resolve fast; when it does advertise one, this floors it so a server -#: sending `0` cannot spin the client in a tight loop. -MIN_POLL_INTERVAL = 0.02 - -_TERMINAL_STATES = frozenset({"completed", "failed", "cancelled"}) - - -# --------------------------------------------------------------------------- -# Wire senders (tasks/get, tasks/update, tasks/cancel) over a ClientSession -# --------------------------------------------------------------------------- - - -def _trace_meta() -> mcp_types.RequestParamsMeta | None: - """Trace context for a task management request, for the current client span. - - Task management calls (`tasks/get`/`update`/`cancel`) use ordinary - client-to-server trace propagation, so their server-side spans nest under - the client span rather than becoming disconnected trace roots. - """ - return cast("mcp_types.RequestParamsMeta | None", inject_trace_context(None)) - - -async def _send_get( - session: ClientSession, - task_id: str, - read_timeout_seconds: float | None = None, -) -> ClientGetTaskResult: - """Send `tasks/get` and parse the detailed task response.""" - with client_span("tasks/get", "tasks/get", task_id): - request = GetTaskRequest( - params=GetTaskRequestParams(task_id=task_id, meta=_trace_meta()) - ) - return await session.send_request( - request, - ClientGetTaskResult, - request_read_timeout_seconds=read_timeout_seconds, - ) - - -async def _send_update( - session: ClientSession, - task_id: str, - input_responses: dict[str, Any], - read_timeout_seconds: float | None = None, -) -> None: - """Send `tasks/update` delivering the caller's answers to a parked task.""" - with client_span("tasks/update", "tasks/update", task_id): - request = UpdateTaskRequest( - params=UpdateTaskRequestParams( - task_id=task_id, input_responses=input_responses, meta=_trace_meta() - ) - ) - await session.send_request( - request, mcp_types.Result, request_read_timeout_seconds=read_timeout_seconds - ) - - -async def _send_cancel( - session: ClientSession, - task_id: str, - read_timeout_seconds: float | None = None, -) -> None: - """Send `tasks/cancel` to cooperatively cancel a task.""" - with client_span("tasks/cancel", "tasks/cancel", task_id): - request = CancelTaskRequest( - params=CancelTaskRequestParams(task_id=task_id, meta=_trace_meta()) - ) - await session.send_request( - request, mcp_types.Result, request_read_timeout_seconds=read_timeout_seconds - ) - - -# --------------------------------------------------------------------------- -# Poll cadence -# --------------------------------------------------------------------------- - - -def _poll_ceiling(poll_interval_ms: float | None) -> float: - """The upper bound for the poll backoff, in seconds. - - A server-advertised `pollIntervalMs` is a deliberate statement about how much - load the server wants to take, so it caps the backoff. A zero, negative, or - absent value falls back to the `poll_interval` client setting; the ceiling is - never below `MIN_POLL_INTERVAL` so a hostile `0` cannot spin the client. - """ - if poll_interval_ms is not None and poll_interval_ms > 0: - return max(poll_interval_ms / 1000, MIN_POLL_INTERVAL) - return client_settings.poll_interval - - -def _next_poll_delay( - poll_interval_ms: float | None, backoff: float -) -> tuple[float, float]: - """Delay before the next poll, plus the backoff for the round after. - - With no status notifications on the modern protocol, polling is the only - signal, so a fixed cadence at the server's advertised interval would make a - quick task take that full interval to observe as done. Instead the backoff - ramps from `MIN_POLL_INTERVAL`, doubling each round up to the ceiling - (`_poll_ceiling`): a quick task resolves in ~20ms while a long one settles to - the server's advertised cadence, hammering neither. - """ - ceiling = _poll_ceiling(poll_interval_ms) - return min(backoff, ceiling), min(backoff * 2, ceiling) - - -# --------------------------------------------------------------------------- -# In-task input: answer a parked task's requests via the elicitation handler -# --------------------------------------------------------------------------- - - -async def _answer_input_requests( - session: ClientSession, - task_id: str, - input_requests: dict[str, Any], - elicitation_callback: ElicitationFnT | None, - read_timeout_seconds: float | None = None, -) -> None: - """Answer a task's outstanding input requests, then deliver via `tasks/update`. - - Each request is surfaced by a server-minted key and carries a serialized - `ElicitRequest`. The client's elicitation handler produces each answer; the - keyed answers are sent back with `tasks/update`, which re-enters the task. - Sampling and roots requests are not supported on the modern protocol. - """ - if elicitation_callback is None: - raise ToolError( - f"Task {task_id} requires input but the client has no elicitation " - "handler; pass elicitation_handler= to Client() to drive tasks that " - "ask for input." - ) - - # Bound the whole answer phase — elicitation callbacks included — by the - # call's remaining budget: a stalled handler must not outlast `timeout=N` - # any more than a stalled poll does, matching the synchronous path. - loop = asyncio.get_event_loop() - deadline = ( - None if read_timeout_seconds is None else loop.time() + read_timeout_seconds - ) - - def _remaining() -> float | None: - if deadline is None: - return None - left = deadline - loop.time() - if left <= 0: - raise TimeoutError(f"Task {task_id} timed out awaiting input") - return left - - responses: dict[str, Any] = {} - for surfaced_key, payload in input_requests.items(): - method = payload.get("method") if isinstance(payload, dict) else None - if method != "elicitation/create": - raise ToolError( - f"Task {task_id} requested in-task input via {method!r}, which the " - "client cannot answer; only elicitation is supported on the modern " - "protocol (sampling and roots are deprecated)." - ) - request = mcp_types.ElicitRequest.model_validate(payload) - context = ClientRequestContext( - session=session, request_id=f"task-{task_id}-{surfaced_key}" - ) - budget = _remaining() - call = elicitation_callback(context, request.params) - try: - answer = await ( - asyncio.wait_for(call, budget) if budget is not None else call - ) - except asyncio.TimeoutError as exc: - # Normalize to the builtin: on Python 3.10 `asyncio.wait_for` raises - # `asyncio.TimeoutError`, a distinct type from the builtin the rest of - # the drive raises (they were unified in 3.11). - raise TimeoutError(f"Task {task_id} timed out awaiting input") from exc - if isinstance(answer, mcp_types.ErrorData): - raise ToolError(f"Elicitation for task {task_id} failed: {answer.message}") - responses[surfaced_key] = answer.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - - await _send_update(session, task_id, responses, _remaining()) - - -# --------------------------------------------------------------------------- -# The shared poll loop -# --------------------------------------------------------------------------- - - -async def _drive_to_terminal( - session: ClientSession, - task_id: str, - elicitation_callback: ElicitationFnT | None, - timeout_seconds: float | None = None, -) -> ClientGetTaskResult: - """Poll `tasks/get` until the task reaches a terminal state. - - `working` sleeps and polls again; `input_required` answers the outstanding - requests through the elicitation handler and re-enters; a terminal state - (completed / failed / cancelled) is returned. Shared by the transparent - resolver and `ToolTask.result()`. - - `timeout_seconds`, when set, is one deadline for the *entire* drive — not a - per-request timeout. The synchronous path aborts a `tools/call` once total - execution exceeds the call's timeout, so the tasked path must too: each poll - and sleep is bounded by the time remaining, and a `TimeoutError` is raised - once the deadline passes. `None` drives to completion unbounded (the default - for `ToolTask.result()`, whose caller bounds waiting via `wait(timeout=...)`). - """ - loop = asyncio.get_event_loop() - deadline = None if timeout_seconds is None else loop.time() + timeout_seconds - backoff = MIN_POLL_INTERVAL - - def remaining() -> float | None: - return None if deadline is None else deadline - loop.time() - - while True: - budget = remaining() - if budget is not None and budget <= 0: - raise TimeoutError( - f"Task {task_id} did not finish within {timeout_seconds}s" - ) - - current = await _send_get(session, task_id, budget) - if current.status in _TERMINAL_STATES: - return current - if current.status == "input_required": - await _answer_input_requests( - session, - task_id, - current.input_requests or {}, - elicitation_callback, - remaining(), - ) - backoff = MIN_POLL_INTERVAL - continue - # working - delay, backoff = _next_poll_delay(current.poll_interval_ms, backoff) - budget = remaining() - if budget is not None: - delay = min(delay, budget) - await asyncio.sleep(delay) - - -def _inlined_call_tool_result(result: dict[str, Any] | None) -> CallToolResult: - """Parse a completed task's inlined result dict into a `CallToolResult`.""" - return CallToolResult.model_validate(result or {}) - - -def _terminal_error_message(final: ClientGetTaskResult) -> str: - """The best available error message for a failed task.""" - if isinstance(final.error, dict): - message = final.error.get("message") - if isinstance(message, str) and message: - return message - if final.status_message: - return final.status_message - return f"Task {final.task_id} failed" - - -# --------------------------------------------------------------------------- -# The tasks client extension and its claim resolver -# --------------------------------------------------------------------------- - - -class TasksClientExtension(ClientExtension): - """The client half of the `io.modelcontextprotocol/tasks` extension (SEP-2663). - - Advertising this extension tells the server the client can drive tasks, so a - `task=True` tool may run as a task; the declared `ResultClaim` then resolves - the `CreateTaskResult` the server returns by polling `tasks/get` to the real - result. Registered automatically on every FastMCP `Client`. - """ - - identifier = TASKS_EXTENSION_ID - - def __init__(self, elicitation_callback: ElicitationFnT | None = None) -> None: - self._elicitation_callback = elicitation_callback - - def settings(self) -> dict[str, Any]: - """The tasks extension advertises no per-extension settings.""" - return {} - - def claims(self) -> Sequence[ResultClaim[Any]]: - return ( - ResultClaim( - result_type="task", - model=ClientCreateTaskResult, - resolve=self._resolve_task, - protocol_versions=frozenset(MODERN_PROTOCOL_VERSIONS), - ), - ) - - async def _resolve_task( - self, create_result: ClientCreateTaskResult, ctx: ClaimContext - ) -> CallToolResult: - """Finish a tasked `tools/call` by polling `tasks/get` to completion. - - Returns the tool's real result on completion; a failed or cancelled task - becomes an error `CallToolResult` so the ordinary `call_tool` error path - (raise `ToolError`) applies uniformly, and the completed inlined result is - schema-valid so the SDK's output-schema revalidation passes. - """ - final = await _drive_to_terminal( - ctx.session, - create_result.task_id, - self._elicitation_callback, - ctx.read_timeout_seconds, - ) - if final.status == "completed": - return _inlined_call_tool_result(final.result) - if final.status == "failed": - message = _terminal_error_message(final) - else: - message = f"Task {final.task_id} was cancelled" - return CallToolResult( - content=[mcp_types.TextContent(type="text", text=message)], - is_error=True, - ) - - -def _build_tasks_client_extension( - elicitation_callback: ElicitationFnT | None, -) -> ClientExtension: - """Factory registered with core so every `Client` folds in task support.""" - return TasksClientExtension(elicitation_callback) - - -# --------------------------------------------------------------------------- -# The explicit task handle (return-quickly surface) -# --------------------------------------------------------------------------- - - -class ToolTask: - """A handle to a tool call the server is running as a background task. - - Returned by `call_tool_task`. Lets a caller return immediately and then drive - the task: check `status`, `wait` for a state, get the finished `result` - (answering any input prompts through the client's elicitation handler), or - `cancel`. Awaiting the handle is shorthand for `result()`. - """ - - def __init__( - self, - client: Client, - tool_name: str, - create_result: ClientCreateTaskResult, - *, - raise_on_error: bool = True, - ) -> None: - self._client = client - self._tool_name = tool_name - self._create_result = create_result - self._raise_on_error = raise_on_error - self._cached_result: FastMCPCallToolResult | None = None - - @property - def task_id(self) -> str: - """The server-generated task id.""" - return self._create_result.task_id - - @property - def create_result(self) -> ClientCreateTaskResult: - """The raw `CreateTaskResult` the server returned for the tasked call.""" - return self._create_result - - @property - def _session(self) -> ClientSession: - return self._client.session - - @property - def _elicitation_callback(self) -> ElicitationFnT | None: - return self._client._elicitation_callback - - async def status(self) -> ClientGetTaskResult: - """Fetch the task's current status via `tasks/get`.""" - return await _send_get(self._session, self.task_id) - - async def wait( - self, *, state: str | None = None, timeout: float = 300.0 - ) -> ClientGetTaskResult: - """Poll until the task reaches `state` (or any terminal state if `None`). - - Does not answer input prompts: a caller that wants automatic answering - should use `result()`. `wait(state="input_required")` lets a caller - observe the parked state and answer it manually. - """ - loop = asyncio.get_event_loop() - deadline = loop.time() + timeout - backoff = MIN_POLL_INTERVAL - while True: - remaining = deadline - loop.time() - if remaining <= 0: - raise TimeoutError( - f"Task {self.task_id} did not reach " - f"{state or 'a terminal state'} within {timeout}s" - ) - # Bound the request itself by the remaining deadline: a stalled - # `tasks/get` must not block past the caller's timeout waiting for - # the session-wide default before the deadline is next checked. - current = await _send_get( - self._session, self.task_id, read_timeout_seconds=remaining - ) - if state is not None: - if current.status == state: - return current - elif current.status in _TERMINAL_STATES: - return current - remaining = deadline - loop.time() - if remaining <= 0: - raise TimeoutError( - f"Task {self.task_id} did not reach " - f"{state or 'a terminal state'} within {timeout}s" - ) - delay, backoff = _next_poll_delay(current.poll_interval_ms, backoff) - # Never sleep past the deadline, so `wait` returns on time rather - # than up to one poll interval late. - await asyncio.sleep(min(delay, remaining)) - - async def result(self) -> FastMCPCallToolResult: - """Drive the task to completion and return its parsed result. - - Answers any input prompts through the client's elicitation handler. - Raises `ToolError` on a failed or cancelled task when `raise_on_error` - (the default); otherwise returns an error result. The result is cached, so - repeated calls return the same object. - """ - if self._cached_result is not None: - return self._cached_result - - final = await _drive_to_terminal( - self._session, self.task_id, self._elicitation_callback - ) - if final.status == "completed": - mcp_result = _inlined_call_tool_result(final.result) - else: - if final.status == "failed": - message = _terminal_error_message(final) - else: - message = f"Task {self.task_id} was cancelled" - if self._raise_on_error: - raise ToolError(message) - mcp_result = CallToolResult( - content=[mcp_types.TextContent(type="text", text=message)], - is_error=True, - ) - - parsed = await self._client._parse_call_tool_result( - self._tool_name, mcp_result, raise_on_error=self._raise_on_error - ) - self._cached_result = parsed - return parsed - - async def cancel(self) -> None: - """Request cooperative cancellation of the task via `tasks/cancel`.""" - await _send_cancel(self._session, self.task_id) - - def __await__(self): - return self.result().__await__() - - -async def call_tool_task( - client: Client, - name: str, - arguments: dict[str, Any] | None = None, - *, - timeout: float | int | None = None, - raise_on_error: bool = True, - version: str | None = None, - meta: dict[str, Any] | None = None, -) -> ToolTask: - """Call a tool as a background task and return a `ToolTask` handle immediately. - - Unlike `client.call_tool` (which polls to completion transparently), this - returns as soon as the server accepts the task, so the caller can do other - work and drive the task through the handle. Requires the server to run the - call as a task (a `task=True` tool on a task-serving backend); a call the - server runs synchronously raises `ToolError`. - - `version` targets a specific component version, the same as - `client.call_tool(..., version=...)`: the server tasks that version rather - than the highest. It is carried in the request metadata FastMCP reads. - """ - read_timeout_seconds = normalize_timeout_to_seconds(timeout) - combined_meta: dict[str, Any] = dict(meta) if meta else {} - if version is not None: - fastmcp_meta = dict(combined_meta.get("fastmcp") or {}) - fastmcp_meta["version"] = version - combined_meta["fastmcp"] = fastmcp_meta - with client_span("tools/call", "tools/call", name, tool_name=name): - # Propagate the trace into the tasked submission, like a foreground call. - propagated = inject_trace_context(combined_meta) - request_meta = cast("mcp_types.RequestParamsMeta | None", propagated or None) - raw = await client._await_with_session_monitoring( - client.session.call_tool( - name=name, - arguments=arguments or {}, - read_timeout_seconds=read_timeout_seconds, - meta=request_meta, - allow_claimed=True, - ) - ) - if isinstance(raw, ClientCreateTaskResult): - return ToolTask(client, name, raw, raise_on_error=raise_on_error) - raise ToolError( - f"Tool {name!r} did not run as a task: the server returned a " - f"{type(raw).__name__} instead of a task. Ensure the tool is declared " - "task=True and the connection is modern (mode='auto')." - ) diff --git a/fastmcp_tasks/fastmcp_tasks/client_models.py b/fastmcp_tasks/fastmcp_tasks/client_models.py deleted file mode 100644 index 25b9cc5e2..000000000 --- a/fastmcp_tasks/fastmcp_tasks/client_models.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Client-side wire models for the SEP-2663 tasks extension. - -These mirror the server models in ``models.py`` but flip the alias direction: -the server *produces* the wire (``serialization_alias`` -> camelCase dump), while -the client *consumes* it. The SDK validates both a claimed ``tools/call`` result -and a ``tasks/get`` response with ``model_validate(raw, by_name=False)``, so these -models declare **validation** aliases (``Field(alias="taskId")``) to read the -camelCase wire keys. - -``ClientCreateTaskResult`` is the claim shape the tasks ``ResultClaim`` resolves. -It must subclass ``mcp_types.Result`` (not ``CallToolResult`` / -``InputRequiredResult``) and pin ``result_type`` to ``Literal["task"]`` — the -SDK's ``ResultClaim.__post_init__`` enforces exactly this. ``ClientGetTaskResult`` -is the typed ``tasks/get`` response: the flat task fields plus exactly one of -``result`` (completed), ``error`` (failed), or ``inputRequests`` (input_required). -""" - -from __future__ import annotations - -from typing import Any, Literal - -import mcp_types -from mcp_types import RequestParams, Result -from pydantic import ConfigDict, Field - -__all__ = [ - "TaskStatus", - "ClientCreateTaskResult", - "ClientGetTaskResult", - "GetTaskRequest", - "GetTaskRequestParams", - "UpdateTaskRequest", - "UpdateTaskRequestParams", - "CancelTaskRequest", - "CancelTaskRequestParams", -] - -TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] - - -class _ClientTaskFields(Result): - """The flat task fields shared by every SEP-2663 task result, read from the wire. - - Validation aliases (camelCase) because the SDK validates the server's - ``model_dump(by_alias=True)`` output with ``by_name=False``. - """ - - model_config = ConfigDict(populate_by_name=True) - - task_id: str = Field(alias="taskId") - status: TaskStatus - created_at: str = Field(alias="createdAt") - last_updated_at: str = Field(alias="lastUpdatedAt") - ttl_ms: float | None = Field(default=None, alias="ttlMs") - status_message: str | None = Field(default=None, alias="statusMessage") - poll_interval_ms: float | None = Field(default=None, alias="pollIntervalMs") - - -class ClientCreateTaskResult(_ClientTaskFields): - """The claimed ``tools/call`` result the server returns when it runs a call as a task. - - Pinned to ``resultType: "task"`` so the tasks ``ResultClaim`` can key on it. - The resolver polls ``tasks/get`` from here to the finished result. - """ - - result_type: Literal["task"] = Field(alias="resultType") - - -class ClientGetTaskResult(_ClientTaskFields): - """The typed ``tasks/get`` response: task fields plus the inlined outcome. - - Exactly one of ``result`` / ``error`` / ``input_requests`` is set, matching - the task's status. ``result_type`` is ``"complete"`` because ``tasks/get`` - itself always completes normally, whatever the task's own status. - """ - - result_type: Literal["complete"] = Field(alias="resultType") - result: dict[str, Any] | None = None - error: dict[str, Any] | None = None - input_requests: dict[str, Any] | None = Field(default=None, alias="inputRequests") - - -class GetTaskRequestParams(RequestParams): - """Params for ``tasks/get`` / ``tasks/cancel``: the target task id. - - These are outbound (client -> server), so they carry *serialization* aliases: - the client constructs them by field name and `send_request` dumps them to the - camelCase wire shape with `by_alias=True`. - """ - - model_config = ConfigDict(populate_by_name=True) - - task_id: str = Field(serialization_alias="taskId") - - -CancelTaskRequestParams = GetTaskRequestParams - - -class UpdateTaskRequestParams(RequestParams): - """Params for ``tasks/update``: task id plus the caller's input responses.""" - - model_config = ConfigDict(populate_by_name=True) - - task_id: str = Field(serialization_alias="taskId") - input_responses: dict[str, Any] = Field(serialization_alias="inputResponses") - - -class GetTaskRequest(mcp_types.Request[GetTaskRequestParams, Literal["tasks/get"]]): - """``tasks/get`` request envelope for ``ClientSession.send_request``.""" - - method: Literal["tasks/get"] = "tasks/get" - params: GetTaskRequestParams - - -class UpdateTaskRequest( - mcp_types.Request[UpdateTaskRequestParams, Literal["tasks/update"]] -): - """``tasks/update`` request envelope for ``ClientSession.send_request``.""" - - method: Literal["tasks/update"] = "tasks/update" - params: UpdateTaskRequestParams - - -class CancelTaskRequest( - mcp_types.Request[CancelTaskRequestParams, Literal["tasks/cancel"]] -): - """``tasks/cancel`` request envelope for ``ClientSession.send_request``.""" - - method: Literal["tasks/cancel"] = "tasks/cancel" - params: CancelTaskRequestParams diff --git a/fastmcp_tasks/fastmcp_tasks/components.py b/fastmcp_tasks/fastmcp_tasks/components.py deleted file mode 100644 index e1aae8df1..000000000 --- a/fastmcp_tasks/fastmcp_tasks/components.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Docket-touching component logic relocated from core component classes. - -During the SEP-1686 -> SEP-2663 migration the ``register_with_docket`` / -``add_to_docket`` / ``coerce_task_arguments`` methods were removed from the core -``FastMCPComponent`` classes (Tool, Resource, ResourceTemplate, Prompt). Their -bodies live here as type-dispatched functions that ``TasksExtension`` wires into -the Docket engine, preserving each type's calling convention. - -The functions dispatch on the concrete component type because each type splats -its arguments differently into the Docket-registered callable: - -- ``FunctionTool``/``FunctionResource``/``FunctionResourceTemplate``/``FunctionPrompt`` - register the raw ``fn`` so Docket resolves ALL dependencies (FastMCP's and - Docket-native), and splat their arguments (``**kwargs``) into it. -- Base ``Tool``/``Resource``/``ResourceTemplate``/``Prompt`` register their - ``run``/``read``/``render`` entry point and pass arguments positionally. - -Only tools carry a task-capable ``task_config`` (SEP-2663 is tools-only); the -resource/prompt/template branches are retained for engine completeness, not -because core still declares them. -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from pydantic import ValidationError as PydanticValidationError - -from fastmcp.exceptions import ValidationError -from fastmcp.prompts.base import Prompt -from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.resources.base import Resource -from fastmcp.resources.function_resource import FunctionResource -from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate -from fastmcp.tools.base import Tool -from fastmcp.tools.function_tool import FunctionTool, _resolve_param_hints -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.types import get_cached_typeadapter -from fastmcp_tasks.input_loop import reentrant_task_fn - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - - -def register_component_with_docket(component: FastMCPComponent, docket: Docket) -> None: - """Register a component's callable with Docket for background execution. - - No-ops if ``task_config.mode`` is ``forbidden``. Function-backed components - register their raw ``fn`` (so Docket resolves all dependencies); base - components register their ``run``/``read``/``render`` entry point. - """ - if not component.task_config.supports_tasks(): - return - - if isinstance(component, FunctionTool): - # Run the tool through the guard loop so a body that returns an - # InputRequiredResult drives the reentrant in-task input cycle. The - # wrapper is signature-preserving, so Docket's dependency injection is - # unchanged for a body that never asks for input. - docket.register( - reentrant_task_fn(component.fn, component.name), names=[component.key] - ) - elif isinstance(component, Tool): - # Custom Tool subclasses route through the same wrapper so a raised - # error becomes a masked, completed `is_error` result — matching the - # synchronous `tools/call` path — rather than a Docket `FAILED` task - # that leaks the raw exception text past the server's masking policy. - docket.register( - reentrant_task_fn(component.run, component.name), names=[component.key] - ) - elif isinstance(component, FunctionResource): - docket.register(component.fn, names=[component.key]) - elif isinstance(component, FunctionResourceTemplate): - docket.register(component.fn, names=[component.key]) - elif isinstance(component, ResourceTemplate): - docket.register(component.read, names=[component.key]) - elif isinstance(component, Resource): - docket.register(component.read, names=[component.key]) - elif isinstance(component, FunctionPrompt): - docket.register(component.fn, names=[component.key]) - elif isinstance(component, Prompt): - docket.register(component.render, names=[component.key]) - else: - raise NotImplementedError( - f"{type(component).__name__} does not support Docket registration" - ) - - -async def add_component_to_docket( - component: FastMCPComponent, - docket: Docket, - arguments: dict[str, Any] | None, - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, -) -> Execution: - """Schedule a component for background execution via Docket. - - Handles each component type's calling convention: - - - ``FunctionTool``: splats the arguments dict (``.fn`` expects ``**kwargs``). - - base ``Tool``: passes the arguments dict positionally. - - ``Resource`` (any): no arguments. - - ``FunctionResourceTemplate``: splats the params dict. - - base ``ResourceTemplate``: passes params positionally. - - ``FunctionPrompt``: splats the arguments dict (or empty). - - base ``Prompt``: passes arguments positionally. - """ - if not component.task_config.supports_tasks(): - raise RuntimeError( - f"Cannot add {type(component).__name__} '{component.name}' to docket: " - f"task execution not supported" - ) - - lookup_key = fn_key or component.key - if task_key: - kwargs["key"] = task_key - adder = docket.add(lookup_key, **kwargs) - - if isinstance(component, FunctionTool): - return await adder(**(arguments or {})) - elif isinstance(component, Tool): - return await adder(arguments) - elif isinstance(component, Resource): - return await adder() - elif isinstance(component, FunctionResourceTemplate): - return await adder(**(arguments or {})) - elif isinstance(component, ResourceTemplate): - return await adder(arguments) - elif isinstance(component, FunctionPrompt): - return await adder(**(arguments or {})) - elif isinstance(component, Prompt): - return await adder(arguments) - else: - raise NotImplementedError( - f"{type(component).__name__} does not implement add_to_docket()" - ) - - -def coerce_task_arguments( - component: FastMCPComponent, - arguments: dict[str, Any], - *, - strict: bool = False, -) -> dict[str, Any]: - """Validate and coerce task arguments before any task state is created. - - Called by ``submit_to_docket`` up front, so invalid inputs raise before the - task's Redis metadata and initial status notification exist — otherwise a - coercion failure during queueing would orphan a task the client has already - observed. Only ``FunctionTool`` splats arguments into a typed Python callable - and therefore mirrors the synchronous validation path; every other component - type is a no-op passthrough. - - When ``strict`` is set (server-level ``strict_input_validation``), arguments - are validated in strict mode so the task path rejects lax coercions (e.g. the - string ``"1"`` into an ``int``) exactly as the synchronous call path does. - """ - if not isinstance(component, FunctionTool): - return arguments - - from fastmcp.server.dependencies import without_injected_parameters - - wrapper_fn = without_injected_parameters( - component.fn, run_in_thread=component.run_in_thread - ) - hints = _resolve_param_hints(wrapper_fn) - - coerced = dict(arguments) - for name, value in arguments.items(): - annotation = hints.get(name) - if annotation is None: - continue - adapter = get_cached_typeadapter(annotation) - try: - coerced[name] = adapter.validate_python(value, strict=strict) - except PydanticValidationError as e: - raise ValidationError(str(e), log_level=logging.WARNING) from e - return coerced diff --git a/fastmcp_tasks/fastmcp_tasks/context.py b/fastmcp_tasks/fastmcp_tasks/context.py deleted file mode 100644 index 64d6f474b..000000000 --- a/fastmcp_tasks/fastmcp_tasks/context.py +++ /dev/null @@ -1,641 +0,0 @@ -"""Task context and scoping for background task execution. - -Determines authorization scope (``get_task_scope``), manages the context -snapshot that is captured at task submission and restored in workers -(``TaskContextSnapshot``), and maintains in-process registries for live -sessions and servers. -""" - -from __future__ import annotations - -import json -import logging -import weakref -from collections import OrderedDict -from contextvars import ContextVar -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from fastmcp_tasks.encryption import SnapshotDecryptionError, snapshot_codec -from fastmcp_tasks.keys import ( - leg_number_from_key, - parse_task_key, - task_redis_prefix, -) - -try: - from docket import TaskKey -except ImportError: - - def TaskKey() -> str: # type: ignore[no-redef] - # Stub so this module stays importable without the fastmcp[tasks] - # extra. ``restore_task_snapshot`` is only ever invoked inside a - # Docket worker, where the real ``docket.TaskKey`` sentinel is - # always present. - return "" - - -if TYPE_CHECKING: - from docket import Docket - from mcp.server.session import ServerSession - - from fastmcp.server.context import Context - from fastmcp.server.server import FastMCP - -_logger = logging.getLogger(__name__) - - -def get_task_scope() -> str | None: - """Get the authorization scope for task isolation. - - Returns the raw scope identifier for the current access token, or - ``None`` when no auth context is present (anonymous tasks). - - The scope is composed as ``client_id|sub`` when the token carries a - ``sub`` claim — necessary for fixed-OAuth servers where ``client_id`` is - shared across all users — and falls back to ``client_id`` alone for - DCR/CIMD flows where the client identity is already per-user. - - Encoding for Redis/Docket keys happens at the boundary in ``keys.py``; - this function returns the raw value. - """ - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - if token is None: - return None - sub = token.claims.get("sub") if token.claims else None - if sub: - return f"{token.client_id}|{sub}" - return token.client_id - - -@dataclass(frozen=True, slots=True) -class TaskContextInfo: - """Information about the current background task context. - - Returned by ``get_task_context()`` when running inside a Docket worker. - Contains identifiers needed to communicate with the MCP session. - """ - - task_id: str - """The MCP task ID (server-generated UUID).""" - - task_scope: str | None - """The authorization scope that owns this task, or ``None`` if anonymous.""" - - -def get_task_context() -> TaskContextInfo | None: - """Get the current task context if running inside a background task worker. - - This function extracts task information from the Docket execution context. - Returns None if not running in a task context (e.g., foreground execution). - - Returns: - TaskContextInfo with task_id and task_scope, or None if not in a task. - """ - from fastmcp_tasks.dependencies import is_docket_available - - if not is_docket_available(): - return None - - from docket.dependencies import current_execution - - try: - execution = current_execution.get() - key_parts = parse_task_key(execution.key) - return TaskContextInfo( - task_id=key_parts["client_task_id"], - task_scope=key_parts["task_scope"], - ) - except LookupError: - return None - except (ValueError, KeyError): - return None - - -def get_task_leg_number() -> int: - """Return the current leg number of the running task (1 outside a re-entry). - - Each re-entry after client input runs as a fresh Docket execution under a - per-leg key; the capture wrapper reads this to scope a leg's outstanding - input requests so successive legs never collide in Redis. - """ - from fastmcp_tasks.dependencies import is_docket_available - - if not is_docket_available(): - return 1 - - from docket.dependencies import current_execution - - try: - return leg_number_from_key(current_execution.get().key) - except LookupError: - return 1 - - -def _snapshot_redis_key(docket: Docket, task_scope: str | None, task_id: str) -> str: - """The Redis key holding a task's context snapshot.""" - return docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot") - - -async def refresh_snapshot_ttl( - docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int -) -> None: - """Slide the snapshot key's TTL alongside the task's routing keys. - - An actively polled task refreshes its metadata and leg pointers on every - ``tasks/get``, and the snapshot must live just as long: a re-entered leg - restores the submitting caller from it. Without the refresh, a task parked - on input past the snapshot's creation-time TTL loses the caller, which - means an unauthenticated run without encryption and a failed task with it. - """ - async with docket.redis() as redis: - await redis.expire( - _snapshot_redis_key(docket, task_scope, task_id), ttl_seconds - ) - - -@dataclass(frozen=True, slots=True) -class TaskContextSnapshot: - """All context data snapshotted at task-submission time. - - Stored as a single Redis key per task, restored once in the worker. - """ - - access_token_json: str | None = None - http_headers: dict[str, str] | None = None - origin_request_id: str | None = None - session_id: str | None = None - owning_tool_name: str | None = None - owning_tool_version: str | None = None - - @classmethod - def capture( - cls, - owning_tool_name: str | None = None, - owning_tool_version: str | None = None, - ) -> TaskContextSnapshot: - """Capture current context for background task execution. - - ``owning_tool_name``/``owning_tool_version`` identify the exact tool the - call targeted. A remote worker (separate process) cannot reach the - submitting process's server map, so it re-resolves the owning (child) - server from this name and version against the root — see - ``make_task_context``. The version matters when two versions of the same - mounted tool name live on different child servers. - """ - from fastmcp.server.dependencies import ( - get_access_token, - get_context, - get_http_headers, - ) - - access_token = get_access_token() - ctx = get_context() - request_context = ctx.request_context - try: - session_id = ctx.session_id - except RuntimeError: - session_id = None - return cls( - access_token_json=( - access_token.model_dump_json() if access_token else None - ), - http_headers=get_http_headers(include_all=True) or None, - origin_request_id=( - str(request_context.request_id) if request_context is not None else None - ), - session_id=session_id, - owning_tool_name=owning_tool_name, - owning_tool_version=owning_tool_version, - ) - - @classmethod - def from_json(cls, raw: str | bytes) -> TaskContextSnapshot: - """Deserialize from JSON stored in Redis.""" - if isinstance(raw, bytes): - raw = raw.decode() - parsed = json.loads(raw) - headers = parsed.get("http_headers") - if isinstance(headers, dict): - headers = {str(k).lower(): str(v) for k, v in headers.items()} - return cls( - access_token_json=parsed.get("access_token_json"), - http_headers=headers, - origin_request_id=parsed.get("origin_request_id"), - session_id=parsed.get("session_id"), - owning_tool_name=parsed.get("owning_tool_name"), - owning_tool_version=parsed.get("owning_tool_version"), - ) - - def to_json(self) -> str: - """Serialize to JSON for Redis storage.""" - return json.dumps( - { - "access_token_json": self.access_token_json, - "http_headers": self.http_headers, - "origin_request_id": self.origin_request_id, - "session_id": self.session_id, - "owning_tool_name": self.owning_tool_name, - "owning_tool_version": self.owning_tool_version, - } - ) - - async def save( - self, - docket: Docket, - task_scope: str | None, - task_id: str, - ttl_seconds: int, - ) -> None: - """Store this snapshot as a single Redis key. - - The stored value is encrypted when a ``FASTMCP_TASKS_ENCRYPTION_KEY`` is - configured: this payload carries the caller's bearer token and headers, - and a distributed backend keeps it where the backend's operators can - read it (#4747). - """ - key = _snapshot_redis_key(docket, task_scope, task_id) - payload = snapshot_codec().encode(self.to_json()) - async with docket.redis() as redis: - await redis.set(key, payload, ex=ttl_seconds) - - -# Cache keyed by task_id so stale entries from previous tasks in the same -# asyncio context are automatically ignored (Docket workers may reuse contexts). -_task_snapshot: ContextVar[tuple[str, TaskContextSnapshot] | None] = ContextVar( - "task_snapshot", default=None -) - - -def _remember_snapshot(task_id: str, snapshot: TaskContextSnapshot) -> None: - """Bind a snapshot to the current asyncio context under ``task_id``. - - Nothing outside this task's context sees it; stale entries left in a - reused context are ignored on recall. - """ - _task_snapshot.set((task_id, snapshot)) - - -def _recall_snapshot(task_id: str) -> TaskContextSnapshot | None: - """Return the snapshot bound for ``task_id`` in the current context. - - Returns ``None`` if nothing is bound, or if the bound entry belongs to - a different task (a stale leftover from a reused asyncio context). - """ - cached = _task_snapshot.get() - if cached is not None: - cached_task_id, snapshot = cached - if cached_task_id == task_id: - return snapshot - return None - - -def get_task_session_id() -> str | None: - """Get the session_id for the current background task, if available. - - Reads the cached snapshot set by the worker-level restore dependency. - Returns None if not in a task context or the snapshot wasn't restored. - """ - task_info = get_task_context() - if task_info is None: - return None - snapshot = _recall_snapshot(task_info.task_id) - return snapshot.session_id if snapshot else None - - -async def restore_task_snapshot(key: str = TaskKey()) -> None: - """Worker-level Docket dependency that restores the task-context snapshot. - - Runs before each fastmcp-owned task, populating the snapshot ContextVar - so user code — and any task-scoped dependency like ``_CurrentContext`` — - sees a ready snapshot without touching Redis itself. All Redis I/O - goes through Docket's async client, so cluster URLs and the memory:// - backend work transparently (#3897). Failures are non-fatal: the task - still runs, and sync helpers return ``None`` as they would have before - the snapshot was captured. - - Configuring an encryption key changes that contract. The operator asked for - fail-closed protection, so any failure to retrieve, decrypt, parse, or apply - the snapshot, including a snapshot that is simply missing, escapes this - dependency and fails the task, rather than running the tool without the - submitting caller's identity (#4747). - """ - try: - parts = parse_task_key(key) - except ValueError: - # Non-fastmcp key (e.g. docket scheduler internals) — nothing to do. - return - - from fastmcp.server.dependencies import get_server - from fastmcp_tasks.dependencies import _current_docket - - # Resolved before anything can fail: a misconfigured key (e.g. an empty - # string) raises here and fails the task, and the branches below read - # `codec.protected` to pick between the fail-open and fail-closed contracts. - codec = snapshot_codec() - - try: - docket = get_server()._docket - except RuntimeError: - docket = None - if docket is None: - docket = _current_docket.get() - if docket is None: - if codec.protected: - raise RuntimeError( - "No Docket backend is available to retrieve the protected " - "task snapshot, so the submitting caller cannot be recovered." - ) - return - - task_scope = parts["task_scope"] - task_id = parts["client_task_id"] - try: - async with docket.redis() as redis: - raw = await redis.get(_snapshot_redis_key(docket, task_scope, task_id)) - if raw is None: - if not codec.protected: - return - raise RuntimeError( - "The task's context snapshot is missing (its TTL may have " - "expired), so the submitting caller cannot be recovered." - ) - snapshot = TaskContextSnapshot.from_json(codec.decode(raw)) - _remember_snapshot(task_id, snapshot) - # Restore the ambient request context (auth token, headers) so core's - # get_access_token()/get_http_headers() see the submitting caller inside - # the worker, exactly as a normal request would. - _apply_snapshot_to_context(snapshot) - except SnapshotDecryptionError: - # Docket reports this to the client as a generic dependency-resolution - # failure, so name the cause here. A key mismatch across servers and - # workers is the likely reason and is not guessable from the wire error. - _logger.error( - "Failed to decrypt the task snapshot for %s. Every server and worker " - "on this queue must share the same FASTMCP_TASKS_ENCRYPTION_KEY. The " - "task will fail rather than run without the submitting caller's " - "identity.", - key, - ) - raise - except Exception: - if codec.protected: - _logger.error( - "Failed to restore the protected task snapshot for %s. The task " - "will fail rather than run without the submitting caller's " - "identity.", - key, - exc_info=True, - ) - raise - _logger.warning("Failed to restore task snapshot for %s", key, exc_info=True) - - -# In-process optimization: when the Docket worker runs in the same process as -# the MCP server, we can hand background tasks a live ServerSession so they can -# call session methods directly (e.g. send_notification). In distributed -# deployments where workers are separate processes, these registries will be -# empty and the worker's Context will have session=None — that's fine, because -# elicitation and notifications have Redis-based fallbacks that work across -# process boundaries (see notifications.py and elicitation.py). - -_task_sessions: dict[str, weakref.ref[ServerSession]] = {} -_TASK_SESSION_CONNECTION_REF = "_fastmcp_task_session_ref" -_TASK_SESSION_CLEANUP_REGISTERED = "_fastmcp_task_session_cleanup_registered" - - -def _remove_task_session(session_id: str, ref: weakref.ref[ServerSession]) -> None: - if _task_sessions.get(session_id) is ref: - _task_sessions.pop(session_id) - - -def register_task_session(session_id: str, session: ServerSession) -> None: - """Register a session for in-process background task access. - - Called automatically when a task is submitted to Docket. The session is - stored as a weakref so it doesn't prevent garbage collection when the - client disconnects. - """ - - session_ref = weakref.ref( - session, lambda ref: _remove_task_session(session_id, ref) - ) - _task_sessions[session_id] = session_ref - - connection = getattr(session, "_connection", None) - if connection is None: - return - - state = connection.state - state[_TASK_SESSION_CONNECTION_REF] = (session_id, session_ref) - if state.get(_TASK_SESSION_CLEANUP_REGISTERED): - return - - def remove_connection_session() -> None: - registered = state.pop(_TASK_SESSION_CONNECTION_REF, None) - if registered is not None: - registered_session_id, registered_ref = registered - _remove_task_session(registered_session_id, registered_ref) - - connection.exit_stack.callback(remove_connection_session) - state[_TASK_SESSION_CLEANUP_REGISTERED] = True - - -def get_task_session(session_id: str) -> ServerSession | None: - """Get a registered session by ID if still alive. - - Returns None in distributed workers where the session lives in another - process — callers must handle this gracefully. - """ - ref = _task_sessions.get(session_id) - if ref is None: - return None - session = ref() - if session is None: - _task_sessions.pop(session_id, None) - return session - - -_task_server_map: OrderedDict[str, weakref.ref[FastMCP]] = OrderedDict() -_TASK_SERVER_MAP_MAX_SIZE = 10_000 - - -def register_task_server(task_id: str, server: FastMCP) -> None: - """Register the server for a background task. - - Called at task-submission time so that background workers can resolve - the correct (child) server for mounted tasks. - """ - _task_server_map[task_id] = weakref.ref(server) - while len(_task_server_map) > _TASK_SERVER_MAP_MAX_SIZE: - _task_server_map.popitem(last=False) - - -def get_task_server(task_id: str) -> FastMCP | None: - """Get the registered server for a background task, if still alive.""" - ref = _task_server_map.get(task_id) - if ref is None: - return None - server = ref() - if server is None: - _task_server_map.pop(task_id, None) - return server - - -def resolve_worker_server() -> FastMCP | None: - """Return the server owning the current task's tool, or None outside a task. - - Installed as core's worker-server resolver by ``TasksExtension`` so - ``get_server()``/``CurrentFastMCP()`` inside a worker resolve to the (child) - server the task was submitted against, not the root that runs the worker. - The map is populated at submission (same process) and, for a remote worker, - by ``make_task_context`` re-resolving from the snapshot before the tool runs. - """ - task_info = get_task_context() - if task_info is None: - return None - return get_task_server(task_info.task_id) - - -async def _resolve_owning_server( - snapshot: TaskContextSnapshot | None, -) -> FastMCP | None: - """Re-resolve a mounted task's owning child server from the root (remote worker). - - A separate worker process cannot reach the submitting process's server map, - so the owning server is recovered by looking the snapshotted tool name up on - the root: a mounted tool resolves to a ``FastMCPProviderTool`` referencing - its child server. Returns ``None`` for an unmounted tool (the root owns it) - or when the name no longer resolves, so the caller falls back to the root. - """ - if snapshot is None or snapshot.owning_tool_name is None: - return None - from fastmcp.exceptions import NotFoundError - from fastmcp.server.dependencies import get_server - from fastmcp.server.providers.fastmcp_provider import FastMCPProviderTool - from fastmcp.utilities.versions import VersionSpec - - root = get_server() - # Resolve the exact version the call targeted: two versions of the same - # mounted tool name can live on different child servers, so omitting the - # version could pick the wrong server's state and masking policy. - version = ( - VersionSpec(eq=snapshot.owning_tool_version) - if snapshot.owning_tool_version - else None - ) - try: - tool = await root.get_tool(snapshot.owning_tool_name, version) - except NotFoundError: - return None - if isinstance(tool, FastMCPProviderTool): - return tool._server - return None - - -def _apply_snapshot_to_context(snapshot: TaskContextSnapshot) -> None: - """Populate the ambient request context a worker's tool body reads. - - A Docket worker has no live request or SDK auth context — especially a - Redis-backed worker in a separate process. This restores the context vars a - tool reads so ``get_access_token()`` / ``get_http_headers()`` work unchanged: - the SDK auth context var (from the snapshotted token) and core's background - task-headers var (from the snapshotted headers). It deliberately does *not* - fabricate a live ``Request``, so ``get_http_request()`` / ``CurrentRequest()`` - still raise inside a task — there is no request. Runs inside - ``restore_task_snapshot`` (a Docket dependency), whose context vars propagate - to the tool the same way the snapshot var already does. - - Both vars are set unconditionally to *this* snapshot's state (``None`` when - it carries no token/headers), never left as-is: a Docket worker may reuse an - asyncio context across tasks, so an anonymous task following an authenticated - one must not inherit the prior caller's identity or headers. - """ - import time - - from mcp.server.auth.middleware.auth_context import auth_context_var - from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser - - from fastmcp.server.auth import AccessToken - from fastmcp.server.dependencies import ( - _background_task_headers, - _background_task_session_id, - ) - - user: AuthenticatedUser | None = None - if snapshot.access_token_json is not None: - token = AccessToken.model_validate_json(snapshot.access_token_json) - # A task may sit queued past its submitter's token expiry. Install it - # only if still valid — mirroring the SDK's bearer check — so a delayed - # task never runs under credentials a live request would reject (401). - # An expired token leaves the worker unauthenticated, the honest state. - if token.expires_at is None or token.expires_at >= int(time.time()): - user = AuthenticatedUser(token) - auth_context_var.set(user) - - _background_task_headers.set( - dict(snapshot.http_headers) if snapshot.http_headers else None - ) - _background_task_session_id.set(snapshot.session_id) - - -async def make_task_context() -> Context | None: - """Build and enter a worker ``Context`` for the current background task. - - Installed as core's background-context factory by ``TasksExtension`` so a - ``ctx: Context`` parameter resolves inside a Docket worker. Returns ``None`` - when not running in a task (so core falls through to its usual error). The - snapshot restored by ``restore_task_snapshot`` supplies the origin request - id; the server prefers the one registered at submission time so mounted - tasks resolve to the child server. No live session is attached — SEP-2663 - input and status are polled, so the worker needs no back-channel. - - For a re-entered leg (after the client answered a guard ask), the accumulated - per-leg state is loaded and injected so the tool reads ``ctx.input_responses`` - / ``ctx.request_state`` identically to the foreground guard contract. Leg 1 - loads nothing (both ``None``). - """ - from fastmcp.server.context import Context - from fastmcp.server.dependencies import get_server - - task_info = get_task_context() - if task_info is None: - return None - - snapshot = _recall_snapshot(task_info.task_id) - server = get_task_server(task_info.task_id) - if server is None: - # In-process submission map missed — this is a remote worker (separate - # process). Re-resolve the owning (child) server from the root using the - # snapshotted tool name, and register it so `CurrentFastMCP()` mid-tool - # resolves the child too. Falls back to the root when unmounted or - # unresolvable. - server = await _resolve_owning_server(snapshot) or get_server() - register_task_server(task_info.task_id, server) - origin_request_id = snapshot.origin_request_id if snapshot else None - - ctx = Context( - fastmcp=server, - session=None, - task_id=task_info.task_id, - origin_request_id=origin_request_id, - ) - await ctx.__aenter__() - - docket = server._docket - if docket is None: - from fastmcp_tasks.dependencies import _current_docket - - docket = _current_docket.get() - if docket is not None: - from fastmcp_tasks.input_store import load_pending_input - - request_state, input_responses = await load_pending_input( - docket, task_info.task_scope, task_info.task_id - ) - ctx._task_request_state = request_state - ctx._task_input_responses = input_responses - - return ctx diff --git a/fastmcp_tasks/fastmcp_tasks/creation.py b/fastmcp_tasks/fastmcp_tasks/creation.py deleted file mode 100644 index 7fc65ead9..000000000 --- a/fastmcp_tasks/fastmcp_tasks/creation.py +++ /dev/null @@ -1,245 +0,0 @@ -"""SEP-2663 task creation: enqueue an augmented tool call to Docket. - -Adapted from the SEP-1686 ``submit_to_docket`` path. The wire surface changed -(a flat ``CreateTaskResult`` with ``ttlMs``/``pollIntervalMs``, no client-supplied -task id or ttl) and the SEP-1686 push machinery — the initial status -notification, the per-task subscription, and the notification subscriber — is -gone, because SEP-2663 in-task input and status are polled, not pushed. The -operational core is preserved: strict argument coercion up front, a -server-generated high-entropy task id, the auth-scoped compound key, the context -snapshot restored in the worker, and durable creation (metadata is written -before the result is returned, so a subsequent ``tasks/get`` always resolves). -""" - -from __future__ import annotations - -import asyncio -import secrets -from datetime import datetime, timezone -from typing import TYPE_CHECKING - -from mcp.shared.exceptions import MCPError -from mcp_types import INTERNAL_ERROR - -from fastmcp.tools.base import Tool -from fastmcp.tools.function_tool import _strict_input_validation -from fastmcp.utilities.logging import get_logger -from fastmcp_tasks.components import add_component_to_docket, coerce_task_arguments -from fastmcp_tasks.context import ( - TaskContextSnapshot, - get_task_scope, - register_task_server, -) -from fastmcp_tasks.dependencies import _current_docket -from fastmcp_tasks.input_store import save_current_leg, save_task_args -from fastmcp_tasks.keys import build_task_key, task_redis_prefix -from fastmcp_tasks.models import CreateTaskResult - -if TYPE_CHECKING: - from docket import Docket - - from fastmcp.server.context import Context - from fastmcp.server.server import FastMCP - -logger = get_logger(__name__) - -# Redis mapping TTL buffer: keep task metadata a little longer than the Docket -# execution TTL so a client polling right at the edge still resolves the task. -TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60 - -# Bounded read-your-writes wait so durable creation holds on distributed -# backends where the enqueued execution may not be immediately visible. -_DURABLE_CREATE_TIMEOUT_SECONDS = 5.0 -_DURABLE_CREATE_POLL_SECONDS = 0.02 - - -async def create_task( - tool: Tool, - arguments: dict[str, object] | None, - context: Context, -) -> CreateTaskResult: - """Run an augmented ``tools/call`` as a background task (SEP-2663). - - Coerces and validates arguments (honoring strict input validation), mints a - server-generated task id, snapshots the request context, enqueues the tool's - callable on Docket under the auth-scoped compound key, and returns a - ``CreateTaskResult`` in ``working`` status. Does not return until the task's - metadata is durably written and its execution is visible, so an immediately - following ``tasks/get`` resolves. - """ - # The interceptor resolves the tool via get_tool(), which for a mounted tool - # returns a provider wrapper — but Docket registered the underlying component - # from get_tasks() under the same key, with that component's calling - # convention (a FunctionTool splats **kwargs; a base Tool takes the dict - # positionally). Execute against the registered component so coercion and - # argument-splatting match what the worker will invoke. - component = await _registered_task_component(context, tool) - - raw_arguments = dict(arguments or {}) - coerced = coerce_task_arguments( - component, raw_arguments, strict=_strict_input_validation() - ) - - task_id = secrets.token_urlsafe(32) - created_at = datetime.now(timezone.utc).isoformat() - - task_scope = get_task_scope() - - docket = context.fastmcp._docket or _current_docket.get() - if docket is None: - raise MCPError( - code=INTERNAL_ERROR, - message="Background tasks require a running tasks extension (Docket).", - ) - - # Resolve mounted tasks to the owning (child) server in the worker, so - # CurrentFastMCP()/ctx.fastmcp inside the task point at the server the tool - # lives on rather than the root the interceptor ran on (#3571). - register_task_server(task_id, _owning_server(tool, context.fastmcp)) - - key = component.key - task_key = build_task_key(task_scope, task_id, "tool", key) - - ttl_ms = int(docket.execution_ttl.total_seconds() * 1000) - ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS - poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000) - - prefix = task_redis_prefix(task_scope) - task_meta_key = docket.key(f"{prefix}:{task_id}") - created_at_key = docket.key(f"{prefix}:{task_id}:created_at") - poll_interval_key = docket.key(f"{prefix}:{task_id}:poll_interval") - - snapshot = TaskContextSnapshot.capture( - owning_tool_name=tool.name, owning_tool_version=tool.version - ) - - async with docket.redis() as redis: - await redis.set(task_meta_key, task_key, ex=ttl_seconds) - await redis.set(created_at_key, created_at, ex=ttl_seconds) - await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds) - - # End-and-reenter state: the raw (wire) arguments feed every leg, re-coerced - # per leg, and the leg pointer starts at leg 1 (the base task key). A guard - # return re-enters by enqueuing the next leg with these same arguments (see - # handlers.enqueue_next_leg). - await save_task_args(docket, task_scope, task_id, raw_arguments, ttl_seconds) - await save_current_leg(docket, task_scope, task_id, task_key, 1, ttl_seconds) - - await snapshot.save(docket, task_scope, task_id, ttl_seconds) - - await add_component_to_docket( - component, docket, coerced, fn_key=key, task_key=task_key - ) - - await _await_durable_creation(docket, task_key) - - return CreateTaskResult( - task_id=task_id, - status="working", - created_at=created_at, - last_updated_at=created_at, - ttl_ms=ttl_ms, - poll_interval_ms=poll_interval_ms, - ) - - -def _owning_server(tool: Tool, fallback: FastMCP) -> FastMCP: - """The server a mounted tool lives on, for worker context resolution. - - A mounted tool is a ``FastMCPProviderTool`` that references the child server - it came from, so ``CurrentFastMCP()``/``ctx.fastmcp`` inside the task point at - that server rather than the root the interceptor ran on (#3571). Resolution - is single-level: a tool reached through several nested mounts resolves to the - outermost mounted child (the mount point the call arrived through), which - still reaches deeper components through its own mounts. A non-mounted tool - falls back to the server the call arrived on. - """ - from fastmcp.server.providers.fastmcp_provider import FastMCPProviderTool - - if isinstance(tool, FastMCPProviderTool): - return tool._server - return fallback - - -async def registered_component_for_key(server: FastMCP, component_key: str) -> Tool: - """Return the Docket-registered task component matching ``component_key``. - - ``get_tasks()`` yields the same components registered with Docket (the - underlying ``FunctionTool`` for a mounted tool, not a provider wrapper), so - matching by ``key`` recovers the component whose calling convention agrees - with the worker. Used when re-entering a task leg, where only the stored - compound key (not the original ``Tool`` object) is available. - """ - for component in await server.get_tasks(): - if component.key == component_key and isinstance(component, Tool): - return component - raise MCPError( - code=INTERNAL_ERROR, - message=f"No task-enabled component found for {component_key!r}.", - ) - - -async def enqueue_task_leg( - server: FastMCP, - docket: Docket, - component: Tool, - raw_arguments: dict[str, object], - leg_key: str, -) -> None: - """Enqueue a fresh Docket execution (the next leg) for a re-entered task. - - Re-coerces the stored wire arguments (each leg validates independently, as a - foreground retry would) and adds the component's registered callable — the - capture wrapper — under ``leg_key``. Waits for the execution to become - durable so a ``tasks/get`` immediately after ``tasks/update`` resolves. - """ - coerced = coerce_task_arguments( - component, dict(raw_arguments), strict=_strict_input_validation() - ) - await add_component_to_docket( - component, docket, coerced, fn_key=component.key, task_key=leg_key - ) - await _await_durable_creation(docket, leg_key) - - -async def _registered_task_component(context: Context, tool: Tool) -> Tool: - """Return the component Docket registered for ``tool``'s key. - - ``get_tasks()`` yields the same components that were registered with Docket - (the underlying ``FunctionTool`` for a mounted tool, not the provider - wrapper the interceptor's ``get_tool`` returns). Matching by ``key`` recovers - the registered component so the calling convention agrees with the worker. - Falls back to the interceptor's tool if no match is found (e.g. a dynamically - added tool not present at registration time). - """ - for component in await context.fastmcp.get_tasks(): - if component.key == tool.key and isinstance(component, Tool): - return component - return tool - - -async def _await_durable_creation(docket: Docket, task_key: str) -> None: - """Block until the enqueued execution is visible (durable-create MUST). - - The metadata write above already makes ``tasks/get`` resolvable; this extra - check guards distributed backends where the execution record propagates - slightly behind the enqueue. Bounded so a backend hiccup can't hang creation. - """ - deadline = asyncio.get_event_loop().time() + _DURABLE_CREATE_TIMEOUT_SECONDS - while True: - execution = await docket.get_execution(task_key) - if execution is not None: - return - if asyncio.get_event_loop().time() >= deadline: - # SEP-2663 durable-create: a CreateTaskResult MUST NOT be returned - # unless a subsequent tasks/get would resolve. Returning a handle - # that can 404 is the exact failure the requirement forbids, so a - # backend that never surfaces the execution is a create error. - raise MCPError( - code=INTERNAL_ERROR, - message=( - "Task creation did not become durable in time; the task " - "backend did not surface the enqueued execution." - ), - ) - await asyncio.sleep(_DURABLE_CREATE_POLL_SECONDS) diff --git a/fastmcp_tasks/fastmcp_tasks/dependencies.py b/fastmcp_tasks/fastmcp_tasks/dependencies.py deleted file mode 100644 index 0598fe6af..000000000 --- a/fastmcp_tasks/fastmcp_tasks/dependencies.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Docket-specific dependency injection for FastMCP background tasks. - -Moved out of ``fastmcp.server.dependencies`` during the SEP-1686 -> SEP-2663 -migration. These helpers are all docket-touching: the ``require_docket`` -install-hint, the docket/worker ContextVars, and the ``CurrentDocket`` / -``CurrentWorker`` dependencies. Everything here is wire-agnostic engine plumbing -that ``TasksExtension`` drives. - -The generic ``is_docket_available`` probe stays in ``fastmcp.server.dependencies`` -(core's ``Context``/``Progress`` still use it) and is re-exported here for the -tasks package's callers. -""" - -from __future__ import annotations - -import importlib.metadata -from contextvars import ContextVar -from types import TracebackType -from typing import TYPE_CHECKING, cast - -from uncalled_for import Dependency - -from fastmcp.server.dependencies import ( - _MIN_DOCKET_VERSION, - get_server, - is_docket_available, -) - -if TYPE_CHECKING: - from docket import Docket - from docket.worker import Worker - -__all__ = [ - "CurrentDocket", - "CurrentWorker", - "is_docket_available", - "require_docket", -] - - -_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None) -_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None) - - -def require_docket(feature: str) -> None: - """Raise ImportError with install instructions if docket not available. - - Args: - feature: Description of what requires docket (e.g., "`task=True`", - "CurrentDocket()"). Will be included in the error message. - """ - if is_docket_available(): - return - - try: - installed = importlib.metadata.version("pydocket") - except importlib.metadata.PackageNotFoundError: - installed = None - - if installed is None: - detail = ( - "FastMCP background tasks require the `tasks` extra. " - "Install with: pip install 'fastmcp[tasks]'." - ) - else: - detail = ( - f"FastMCP background tasks require pydocket>={_MIN_DOCKET_VERSION}, " - f"but pydocket {installed} is installed (likely pulled in by another " - f"package). Upgrade with: pip install -U 'pydocket>={_MIN_DOCKET_VERSION}'." - ) - - raise ImportError(f"{detail} (Triggered by {feature})") - - -class _CurrentDocket(Dependency["Docket"]): - """Async context manager for Docket dependency.""" - - async def __aenter__(self) -> Docket: - require_docket("CurrentDocket()") - # Check server instance first, fall back to ContextVar for mounted children - # whose parent owns the Docket - try: - docket = get_server()._docket - except RuntimeError: - docket = None - if docket is None: - docket = _current_docket.get() - if docket is None: - raise RuntimeError( - "No Docket instance found. Docket is only initialized when there are " - "task-enabled components (task=True). Add task=True to a component " - "to enable Docket infrastructure." - ) - return docket - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - pass - - -def CurrentDocket() -> Docket: - """Get the current Docket instance managed by FastMCP. - - This dependency provides access to the Docket instance that FastMCP - automatically creates for background task scheduling. - - Returns: - A dependency that resolves to the active Docket instance - - Raises: - RuntimeError: If not within a FastMCP server context - ImportError: If fastmcp[tasks] not installed - - Example: - ```python - from fastmcp_tasks.dependencies import CurrentDocket - - @mcp.tool() - async def schedule_task(docket: Docket = CurrentDocket()) -> str: - await docket.add(some_function)(arg1, arg2) - return "Scheduled" - ``` - """ - require_docket("CurrentDocket()") - return cast("Docket", _CurrentDocket()) - - -class _CurrentWorker(Dependency["Worker"]): - """Async context manager for Worker dependency.""" - - async def __aenter__(self) -> Worker: - require_docket("CurrentWorker()") - # Check server instance first, fall back to ContextVar for mounted children - try: - worker = get_server()._worker - except RuntimeError: - worker = None - if worker is None: - worker = _current_worker.get() - if worker is None: - raise RuntimeError( - "No Worker instance found. Worker is only initialized when there are " - "task-enabled components (task=True). Add task=True to a component " - "to enable Docket infrastructure." - ) - return worker - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - pass - - -def CurrentWorker() -> Worker: - """Get the current Docket Worker instance managed by FastMCP. - - This dependency provides access to the Worker instance that FastMCP - automatically creates for background task processing. - - Returns: - A dependency that resolves to the active Worker instance - - Raises: - RuntimeError: If not within a FastMCP server context - ImportError: If fastmcp[tasks] not installed - - Example: - ```python - from fastmcp_tasks.dependencies import CurrentWorker - - @mcp.tool() - async def check_worker_status(worker: Worker = CurrentWorker()) -> str: - return f"Worker: {worker.name}" - ``` - """ - require_docket("CurrentWorker()") - return cast("Worker", _CurrentWorker()) diff --git a/fastmcp_tasks/fastmcp_tasks/encryption.py b/fastmcp_tasks/fastmcp_tasks/encryption.py deleted file mode 100644 index 34b4fec12..000000000 --- a/fastmcp_tasks/fastmcp_tasks/encryption.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Encryption of the task-context snapshot at rest. - -The snapshot a task carries holds the submitting caller's access token and every -inbound HTTP header, and it lives in the Docket backend for the task's TTL. A -distributed backend therefore keeps bearer credentials in Redis, where a -``rediss://`` URL protects the wire but not the stored value. - -Setting ``FASTMCP_TASKS_ENCRYPTION_KEY`` turns the stored snapshot into a -Fernet token. The same key must reach every server and worker on the queue, -because the process that restores a snapshot is rarely the one that captured it. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from functools import lru_cache -from typing import ClassVar - -from fastmcp.utilities.logging import get_logger -from fastmcp_tasks.settings import tasks_settings - -logger = get_logger(__name__) - -# Domain separation: FASTMCP_TASKS_ENCRYPTION_KEY may protect other task-owned -# state over time, and each use derives its own Fernet key from this material. -_SNAPSHOT_KEY_SALT = "fastmcp-task-snapshot-key" - -# Below this, warn: the keyspace is small enough that the offline attacker this -# feature defends against can search it even through PBKDF2. Matches the OAuth -# proxy's threshold for its signing-key material. -_SHORT_KEY_WARNING_LENGTH = 12 - -# Every Fernet token starts with the version byte 0x80, which base64url encodes -# (together with the leading zero bytes of its 64-bit timestamp) as "gAAAAA". -# A plaintext snapshot is a JSON object starting with "{", so the prefix cannot -# collide with a legitimately unencrypted value. -_FERNET_PREFIX = "gAAAAA" - - -class SnapshotDecryptionError(Exception): - """A stored snapshot is encrypted but cannot be read by this process. - - Raised for a wrong key, a tampered value, a plaintext value written before - the key was configured, or an encrypted value read by a process with no key - configured at all. The restore path lets this escape so the task fails, - rather than running the tool as an anonymous caller. - """ - - -class SnapshotCodec(ABC): - """Transforms snapshot payloads on their way to and from the backend. - - ``protected`` tells the restore path which failure contract applies: a - protected snapshot that cannot be restored fails the task, an unprotected - one degrades to an anonymous run with a warning. - """ - - protected: ClassVar[bool] - - @abstractmethod - def encode(self, payload: str) -> str: - """Return the stored form of a serialized snapshot.""" - - @abstractmethod - def decode(self, stored: str | bytes) -> str: - """Return the serialized snapshot a stored value holds.""" - - -class PlaintextCodec(SnapshotCodec): - """Stores snapshots as-is; the contract when no encryption key is set. - - It still refuses to decode a Fernet envelope: an encrypted snapshot - reaching a keyless process means the submitter configured a key this - process lacks (a partial rollout, or a lost setting), and passing the - ciphertext through would end in a swallowed parse error and an anonymous - run instead of the configured fail-closed behavior. - """ - - protected = False - - def encode(self, payload: str) -> str: - return payload - - def decode(self, stored: str | bytes) -> str: - text = stored.decode() if isinstance(stored, bytes) else stored - if text.startswith(_FERNET_PREFIX): - raise SnapshotDecryptionError( - "The stored task snapshot is encrypted, but this process has " - "no FASTMCP_TASKS_ENCRYPTION_KEY configured." - ) - return text - - -class EncryptedCodec(SnapshotCodec): - """Encrypts snapshot payloads with a key derived from material. - - The material is a string from the environment, and nothing about a string - proves it is random, so it is always treated as low-entropy: the Fernet key - comes from PBKDF2, never from HKDF. The stretch costs about a second, paid - once per process (see ``_codec_for``). - """ - - protected = True - - def __init__(self, material: str) -> None: - from cryptography.fernet import Fernet - - from fastmcp.server.auth.jwt_issuer import derive_jwt_key - - if not material: - raise ValueError( - "FASTMCP_TASKS_ENCRYPTION_KEY must not be empty. Unset it to store " - "task snapshots as plaintext, or set at least 32 random " - "characters." - ) - if len(material) < _SHORT_KEY_WARNING_LENGTH: - logger.warning( - "The configured encryption key is shorter than %d characters; " - "use at least 32 random characters.", - _SHORT_KEY_WARNING_LENGTH, - ) - key = derive_jwt_key(low_entropy_material=material, salt=_SNAPSHOT_KEY_SALT) - - self._fernet = Fernet(key=key) - - def encode(self, payload: str) -> str: - """Return the encrypted form of a serialized snapshot.""" - return self._fernet.encrypt(payload.encode()).decode() - - def decode(self, stored: str | bytes) -> str: - """Return the serialized snapshot a stored value holds. - - Raises ``SnapshotDecryptionError`` if the value was not produced by this - key, including when it is unencrypted. - """ - from cryptography.fernet import InvalidToken - - raw = stored.encode() if isinstance(stored, str) else stored - try: - return self._fernet.decrypt(raw).decode() - except InvalidToken as e: - raise SnapshotDecryptionError( - "The stored task snapshot could not be decrypted with the " - "configured FASTMCP_TASKS_ENCRYPTION_KEY." - ) from e - - -_PLAINTEXT_CODEC = PlaintextCodec() - - -@lru_cache(maxsize=4) -def _codec_for(material: str) -> EncryptedCodec: - """One codec per key, so the derivation cost is paid once per process. - - The PBKDF2 stretch takes about a second, and every task submission and - every restore needs a codec. - """ - return EncryptedCodec(material) - - -def snapshot_codec() -> SnapshotCodec: - """The codec for the configured key; the plaintext codec when none is set.""" - key = tasks_settings.encryption_key - if key is None: - return _PLAINTEXT_CODEC - return _codec_for(key.get_secret_value()) - - -def clear_codec_cache() -> None: - """Drop the cached codecs, so a changed key takes effect.""" - _codec_for.cache_clear() diff --git a/fastmcp_tasks/fastmcp_tasks/extension.py b/fastmcp_tasks/fastmcp_tasks/extension.py deleted file mode 100644 index 7ba4f123d..000000000 --- a/fastmcp_tasks/fastmcp_tasks/extension.py +++ /dev/null @@ -1,335 +0,0 @@ -"""The SEP-2663 tasks extension: `io.modelcontextprotocol/tasks`. - -`TasksExtension` is the wire adapter that turns FastMCP's task engine into an -`io.modelcontextprotocol/tasks` server extension. Registering it enables -`task=True` tools: - -```python -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension - -mcp = FastMCP("Server") -mcp.add_extension(TasksExtension(url="redis://localhost:6379/0")) - - -@mcp.tool(task=True) -async def crunch(dataset: str) -> str: - ... -``` - -The extension contributes the negotiated capability, the three additive request -methods (`tasks/get`, `tasks/update`, `tasks/cancel`), a `tools/call` interceptor -that decides whether to run a call as a task, and a lifespan that starts the -Docket backend/worker and installs the worker-side `Context` hooks core exposes. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager -from datetime import timedelta -from typing import TYPE_CHECKING, Any - -from mcp.server.context import ServerRequestContext -from mcp.shared.exceptions import MCPError -from mcp.shared.inbound import MCP_NAME_HEADER, decode_header_value -from mcp_types.jsonrpc import HEADER_MISMATCH -from mcp_types.version import MODERN_PROTOCOL_VERSIONS - -from fastmcp.exceptions import NotFoundError -from fastmcp.server.dependencies import extract_version_spec, get_http_request -from fastmcp.server.extensions import ( - MethodBinding, - ServerExtension, - read_client_extension_settings, -) -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.tasks import TASKS_EXTENSION_ID -from fastmcp.utilities.versions import VersionSpec -from fastmcp_tasks.creation import create_task -from fastmcp_tasks.handlers import tasks_cancel, tasks_get, tasks_update -from fastmcp_tasks.models import ( - MISSING_REQUIRED_CLIENT_CAPABILITY, - CancelTaskParams, - CancelTaskResult, - GetTaskParams, - GetTaskResult, - UpdateTaskParams, - UpdateTaskResult, - missing_capability_error_data, -) -from fastmcp_tasks.settings import DocketSettings - -if TYPE_CHECKING: - import mcp_types - - from fastmcp.server.context import Context - from fastmcp.server.extensions import ToolCallContinuation, ToolCallOutcome - -logger = get_logger(__name__) - -# SEP-2663's request methods exist only at the 2026-07-28 era (the extensions -# mechanism itself is era-gated). Off that era the methods report as not found. -_TASK_METHOD_VERSIONS = frozenset(MODERN_PROTOCOL_VERSIONS) - - -class TasksExtension(ServerExtension): - """FastMCP server extension implementing SEP-2663 background tasks. - - Construct with backend/worker configuration; anything omitted falls back to - the ``FASTMCP_DOCKET_*`` environment defaults (unchanged from FastMCP 3), so - ``TasksExtension()`` works out of the box on an env-configured deployment. - """ - - identifier = TASKS_EXTENSION_ID - - def __init__( - self, - *, - url: str | None = None, - name: str | None = None, - worker_name: str | None = None, - concurrency: int | None = None, - redelivery_timeout: timedelta | None = None, - reconnection_delay: timedelta | None = None, - minimum_check_interval: timedelta | None = None, - ) -> None: - overrides: dict[str, Any] = { - "url": url, - "name": name, - "worker_name": worker_name, - "concurrency": concurrency, - "redelivery_timeout": redelivery_timeout, - "reconnection_delay": reconnection_delay, - "minimum_check_interval": minimum_check_interval, - } - self._settings = DocketSettings( - **{k: v for k, v in overrides.items() if v is not None} - ) - - @property - def docket_settings(self) -> DocketSettings: - """The resolved Docket settings (backend URL, worker options).""" - return self._settings - - def settings(self) -> dict[str, Any]: - """The tasks extension advertises no per-extension settings.""" - return {} - - def methods(self) -> Sequence[MethodBinding]: - return [ - MethodBinding( - method="tasks/get", - params_type=GetTaskParams, - handler=self._handle_get, - protocol_versions=_TASK_METHOD_VERSIONS, - ), - MethodBinding( - method="tasks/update", - params_type=UpdateTaskParams, - handler=self._handle_update, - protocol_versions=_TASK_METHOD_VERSIONS, - ), - MethodBinding( - method="tasks/cancel", - params_type=CancelTaskParams, - handler=self._handle_cancel, - protocol_versions=_TASK_METHOD_VERSIONS, - ), - ] - - def _require_tasks_capability(self, ctx: ServerRequestContext[Any, Any]) -> None: - """Reject a task method from a client that did not declare the extension. - - SEP-2663: a client issuing `tasks/get`/`tasks/update`/`tasks/cancel` - without the tasks capability in the request's `_meta` gets -32021. A - client normally only holds a taskId because it declared the capability - on the creating `tools/call`, but the method-level check is an explicit - MUST, so enforce it here rather than assume. - """ - if read_client_extension_settings(ctx, TASKS_EXTENSION_ID) is None: - raise MCPError( - code=MISSING_REQUIRED_CLIENT_CAPABILITY, - message=( - "This request targets the tasks extension " - f"({TASKS_EXTENSION_ID}); the client did not declare it for " - "this request." - ), - data=missing_capability_error_data(), - ) - - def _require_matching_task_route(self, task_id: str) -> None: - """Reject a task method whose `Mcp-Name` header disagrees with its body. - - SEP-2243 mirrors a request's name-shaped field into `Mcp-Name` so - intermediaries can route without parsing the body, and requires servers - that read the body to check the two agree. SEP-2663 extends that to the - tasks namespace, where the name-shaped field is `taskId`. The core SDK's - pre-dispatch ladder only knows the base protocol's name-bearing methods, - so the extension enforces its own. - """ - try: - request = get_http_request() - except RuntimeError: - # Not an HTTP transport, so there are no routing headers to check. - return - header = request.headers.get(MCP_NAME_HEADER) - if header is None: - return - if decode_header_value(header) != task_id: - raise MCPError( - code=HEADER_MISMATCH, - message=( - f"{MCP_NAME_HEADER} header does not match the request body's " - "'taskId' parameter" - ), - ) - - def _check_task_request( - self, ctx: ServerRequestContext[Any, Any], task_id: str - ) -> None: - """Run both gates every `tasks/*` method shares.""" - self._require_tasks_capability(ctx) - self._require_matching_task_route(task_id) - - async def _handle_get( - self, ctx: ServerRequestContext[Any, Any], params: GetTaskParams - ) -> GetTaskResult: - self._check_task_request(ctx, params.task_id) - return await tasks_get(self.server, params.task_id) - - async def _handle_update( - self, ctx: ServerRequestContext[Any, Any], params: UpdateTaskParams - ) -> UpdateTaskResult: - self._check_task_request(ctx, params.task_id) - return await tasks_update(self.server, params.task_id, params.input_responses) - - async def _handle_cancel( - self, ctx: ServerRequestContext[Any, Any], params: CancelTaskParams - ) -> CancelTaskResult: - self._check_task_request(ctx, params.task_id) - return await tasks_cancel(self.server, params.task_id) - - async def intercept_tool_call( - self, - params: mcp_types.CallToolRequestParams, - context: Context, - call_next: ToolCallContinuation, - ) -> ToolCallOutcome: - """Decide whether to run this ``tools/call`` as a task. - - Consults the tool's ``TaskConfig`` mode and the client's per-request - opt-in: ``required`` always tasks (raising -32021 if the client did not - opt in), ``optional`` tasks only when the client opted in, ``forbidden`` - never tasks. A non-task call passes straight through to the tool body. - """ - # Resolve the same version core would dispatch: a versioned tools/call - # carries its VersionSpec in the request _meta, so omitting it here would - # task the highest version even when the client targeted an older one - # (which may differ in task mode or implementation). - version_str = extract_version_spec(params.meta) - version = VersionSpec(eq=version_str) if version_str else None - try: - tool = await context.fastmcp.get_tool(params.name, version) - except NotFoundError: - tool = None - if tool is None or not tool.task_config.supports_tasks(): - return await call_next() - - # Extension negotiation exists only on the modern era: the SDK strips - # `capabilities.extensions` from pre-2026 handshakes, so a legacy client - # cannot have negotiated this extension — a `_meta` opt-in arriving on a - # handshake-era connection is treated as absent. This also keeps a - # `CreateTaskResult` off legacy connections, whose result validation - # does not admit it. - rc = context.request_context - on_modern_era = ( - rc is not None and rc.protocol_version in MODERN_PROTOCOL_VERSIONS - ) - opted_in = ( - on_modern_era - and context.client_extension_settings(TASKS_EXTENSION_ID) is not None - ) - mode = tool.task_config.mode - - if mode == "required": - if not opted_in: - raise MCPError( - code=MISSING_REQUIRED_CLIENT_CAPABILITY, - message=( - f"Tool {tool.name!r} requires the tasks extension " - f"({TASKS_EXTENSION_ID}); the client did not declare it " - "for this request." - ), - data=missing_capability_error_data(), - ) - return await create_task(tool, params.arguments, context) - - if mode == "optional" and opted_in: - return await create_task(tool, params.arguments, context) - - return await call_next() - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - """Start the Docket backend/worker and install the worker-side hooks. - - Installs core's background-context factory and worker-server resolver for - the duration so a worker's ``ctx`` (progress, server resolution) works, - then runs the Docket lifespan. The hooks are process-global and - refcounted: with several servers in one process (each its own - runtime-tree root), the hooks stay installed until the last tasks - extension shuts down, so one server's exit cannot strand another - server's in-flight workers. - """ - from fastmcp_tasks.lifespan import docket_lifespan - - _install_worker_hooks() - try: - async with docket_lifespan(self.server, self._settings): - yield - finally: - _release_worker_hooks() - - -# The worker-side hooks core exposes are process-global, but several servers in -# one process may each run a TasksExtension (sibling roots in tests, or two -# apps sharing an interpreter). Refcount the installs so the hooks are cleared -# only when the last active extension lifespan exits. The installed callables -# are stateless module functions that resolve their target per task, so -# repeated installs are idempotent. -_active_worker_hook_holds: int = 0 - - -def _install_worker_hooks() -> None: - from fastmcp.server.dependencies import ( - set_background_context_factory, - set_worker_server_resolver, - ) - from fastmcp_tasks import wire_production - from fastmcp_tasks.context import make_task_context, resolve_worker_server - - global _active_worker_hook_holds - _active_worker_hook_holds += 1 - set_background_context_factory(make_task_context) - set_worker_server_resolver(resolve_worker_server) - # Enable server-side production of the claimed CreateTaskResult on tools/call - # (the SDK ships only claim consumption). Refcounted independently but - # installed/released in lockstep with the worker hooks. - wire_production.install() - - -def _release_worker_hooks() -> None: - from fastmcp.server.dependencies import ( - set_background_context_factory, - set_worker_server_resolver, - ) - from fastmcp_tasks import wire_production - - global _active_worker_hook_holds - _active_worker_hook_holds -= 1 - if _active_worker_hook_holds <= 0: - _active_worker_hook_holds = 0 - set_worker_server_resolver(None) - set_background_context_factory(None) - wire_production.uninstall() diff --git a/fastmcp_tasks/fastmcp_tasks/handlers.py b/fastmcp_tasks/fastmcp_tasks/handlers.py deleted file mode 100644 index ae9deb996..000000000 --- a/fastmcp_tasks/fastmcp_tasks/handlers.py +++ /dev/null @@ -1,484 +0,0 @@ -"""SEP-2663 task query/management handlers: tasks/get, tasks/update, tasks/cancel. - -Adapted from the SEP-1686 ``requests.py``. The three CRUD-ish handlers survive, -reshaped to the new wire: - -- ``tasks/get`` merges the old ``tasks/get`` and ``tasks/result``: the finished - result is *inlined* into the response for a completed task, a JSON-RPC-shaped - ``error`` for a failed one, and the outstanding ``inputRequests`` for a task - waiting on input. -- ``tasks/update`` is new: it delivers ``inputResponses`` to the in-task input - store, resuming a parked worker. -- ``tasks/cancel`` returns an empty ack (SEP-2663) instead of a task snapshot. -- ``tasks/list`` and ``tasks/result`` are gone (removed by SEP-2663). - -The auth-scoped compound key is the authorization boundary: a request resolves a -task only under its own scope's Redis prefix, so a scope mismatch is -indistinguishable from a missing task (both raise -32602 "Task not found"), -which avoids leaking task existence across callers. -""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Literal - -import mcp_types -from docket.execution import ExecutionState -from mcp.shared.exceptions import MCPError -from mcp_types import INVALID_PARAMS - -from fastmcp.exceptions import NotFoundError -from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult -from fastmcp.utilities.tasks import DEFAULT_POLL_INTERVAL_MS -from fastmcp.utilities.versions import VersionSpec -from fastmcp_tasks.context import get_task_scope, refresh_snapshot_ttl -from fastmcp_tasks.creation import ( - TASK_MAPPING_TTL_BUFFER_SECONDS, - enqueue_task_leg, - registered_component_for_key, -) -from fastmcp_tasks.input_store import ( - acquire_update_lock_blocking, - clear_outstanding, - discard_outstanding, - is_cancelled, - load_current_leg, - load_task_args, - mark_cancelled, - read_outstanding_inputs, - refresh_current_leg_ttl, - release_update_lock, - save_current_leg, - store_input_responses, - translate_responses, -) -from fastmcp_tasks.keys import ( - leg_execution_key, - parse_task_key, - task_redis_prefix, -) -from fastmcp_tasks.models import ( - CancelTaskResult, - GetTaskResult, - UpdateTaskResult, -) - -if TYPE_CHECKING: - from docket import Docket - - from fastmcp.server.server import FastMCP - -# Docket execution state -> SEP-2663 task status. `input_required` is not a -# Docket state; it is derived from the in-task input store (see tasks_get). -DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = { - ExecutionState.SCHEDULED: "working", - ExecutionState.QUEUED: "working", - ExecutionState.RUNNING: "working", - ExecutionState.COMPLETED: "completed", - ExecutionState.FAILED: "failed", - ExecutionState.CANCELLED: "cancelled", -} - - -def _task_not_found(task_id: str) -> MCPError: - """The single "not found" error for missing, expired, or cross-scope ids. - - Uses one message for all three so a caller cannot probe another scope's task - ids by distinguishing "not yours" from "does not exist". - """ - return MCPError(code=INVALID_PARAMS, message=f"Task {task_id} not found") - - -def _normalize_iso_timestamp(stored: str | None) -> str: - """Return an ISO 8601 timestamp for createdAt, tolerating a missing value.""" - if stored: - try: - return datetime.fromisoformat(stored.replace("Z", "+00:00")).isoformat() - except (ValueError, AttributeError): - pass - return datetime.now(timezone.utc).isoformat() - - -def _parse_key_version(key_suffix: str) -> tuple[str, str | None]: - """Split a component key suffix into (name, version) on the last ``@``.""" - if "@" not in key_suffix: - return key_suffix, None - name, version = key_suffix.rsplit("@", 1) - return name, version if version else None - - -def _ttl_ms(docket: Docket) -> int: - """The task TTL in milliseconds, from Docket's execution TTL (server-set).""" - return int(docket.execution_ttl.total_seconds() * 1000) - - -def _task_key_ttl_seconds(docket: Docket) -> int: - """Wall-clock TTL for a task's Redis metadata keys. - - Docket's ``execution_ttl`` plus a buffer (matching task creation), so a key - written or refreshed now comfortably outlives the execution-retention - window. Sliding expiration on each poll keeps it alive for long legs. - """ - return int(docket.execution_ttl.total_seconds()) + TASK_MAPPING_TTL_BUFFER_SECONDS - - -async def _lookup_task( - docket: Docket, task_scope: str | None, task_id: str -) -> tuple[Any, str, int, str | None, int]: - """Resolve a task's current-leg execution and metadata within the scope. - - Returns ``(execution, base_task_key, leg_number, created_at, - poll_interval_ms)``. The execution is the *current leg* (the latest Docket - execution), which for a re-entered task differs from the base task key. - Raises the shared "not found" error when the scope-prefixed metadata is - absent or the current leg's execution has expired. - """ - prefix = task_redis_prefix(task_scope) - meta_key = docket.key(f"{prefix}:{task_id}") - created_at_key = docket.key(f"{prefix}:{task_id}:created_at") - poll_key = docket.key(f"{prefix}:{task_id}:poll_interval") - - async with docket.redis() as redis: - # Docket's Redis client mirrors redis-py's variadic ``mget(*keys)`` at - # runtime; its type stub declares a single ``Sequence`` arg, so the - # positional form is correct but needs a targeted ignore. - values = await redis.mget(meta_key, created_at_key, poll_key) # ty: ignore[too-many-positional-arguments] - task_key_bytes, created_at_bytes, poll_bytes = values - - base_task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None - if not base_task_key: - raise _task_not_found(task_id) - - current_leg_key, leg_number = await load_current_leg(docket, task_scope, task_id) - execution_key = current_leg_key or base_task_key - execution = await docket.get_execution(execution_key) - if not execution: - raise _task_not_found(task_id) - - # Sliding expiration: an actively-polled task refreshes its routing keys so - # they never expire mid-execution — a resumed leg that runs longer than the - # keys' wall-clock TTL would otherwise strand `_lookup_task` on the base leg. - refresh_ttl = _task_key_ttl_seconds(docket) - async with docket.redis() as redis: - await redis.expire(meta_key, refresh_ttl) - await redis.expire(created_at_key, refresh_ttl) - await redis.expire(poll_key, refresh_ttl) - await refresh_current_leg_ttl(docket, task_scope, task_id, refresh_ttl) - # The snapshot must outlive the routing keys it serves: a re-entered leg - # restores the submitting caller from it, and with encryption configured a - # missing snapshot fails the task instead of degrading to an anonymous run. - await refresh_snapshot_ttl(docket, task_scope, task_id, refresh_ttl) - - created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None - - try: - poll_interval_ms = ( - int(poll_bytes.decode("utf-8")) if poll_bytes else DEFAULT_POLL_INTERVAL_MS - ) - except (ValueError, UnicodeDecodeError): - poll_interval_ms = DEFAULT_POLL_INTERVAL_MS - - return execution, base_task_key, leg_number, created_at, poll_interval_ms - - -async def _resolve_tool(server: FastMCP, task_key: str) -> Tool: - """Resolve the Tool a task ran, from its compound key (tools-only surface).""" - component_key = parse_task_key(task_key)["component_identifier"] - if not component_key.startswith("tool:"): - raise MCPError( - code=mcp_types.INTERNAL_ERROR, - message=f"Task component is not a tool: {component_key}", - ) - name, version_str = _parse_key_version(component_key[len("tool:") :]) - version = VersionSpec(eq=version_str) if version_str else None - try: - tool = await server.get_tool(name, version) - except NotFoundError: - tool = None - if tool is None: - raise MCPError( - code=mcp_types.INTERNAL_ERROR, - message=f"Component not found for task: {component_key}", - ) - return tool - - -def _inline_result(tool: Tool, raw_value: Any) -> dict[str, Any]: - """Convert a completed task's raw return into an inlined CallToolResult dict. - - A completed task should never carry an ``InputRequiredResult``: a function - tool's guard returns are captured by the end-and-reenter wrapper (see - ``input_loop.py``), which records the leg's outstanding requests and ends the - leg (returning ``None``), so ``tasks/get`` reports ``input_required`` rather - than inlining. Reaching here with a guard result means a component type the - wrapper does not wrap (e.g. a base ``Tool``) returned one, which the task - path cannot drive — a safety net, not an expected path. - """ - if isinstance(raw_value, mcp_types.InputRequiredResult | InputRequiredToolResult): - raise MCPError( - code=mcp_types.INTERNAL_ERROR, - message=( - f"Tool {tool.name!r} returned an input-required result as a task, " - "but its component type is not driven by the in-task guard loop. " - "Guard-pattern tasks are supported for function tools." - ), - ) - # A raised tool error arrives as an is_error ToolResult the wrapper built - # (end-and-reenter G2); use it directly so isError round-trips. A normal - # return is converted through the tool's own result coercion. - if isinstance(raw_value, ToolResult): - mcp_result = raw_value.to_mcp_result() - else: - mcp_result = tool.convert_result(raw_value).to_mcp_result() - if isinstance(mcp_result, mcp_types.CallToolResult): - call_tool_result = mcp_result - elif isinstance(mcp_result, tuple): - content, structured_content = mcp_result - call_tool_result = mcp_types.CallToolResult( - content=content, structured_content=structured_content - ) - else: - call_tool_result = mcp_types.CallToolResult(content=mcp_result) - return call_tool_result.model_dump(by_alias=True, mode="json", exclude_none=True) - - -async def tasks_get(server: FastMCP, task_id: str) -> GetTaskResult: - """Handle ``tasks/get``: the detailed task with its result/error/inputs inlined.""" - docket = server._docket - if docket is None: - raise _task_not_found(task_id) - - task_scope = get_task_scope() - ( - execution, - base_task_key, - leg_number, - created_at, - poll_interval_ms, - ) = await _lookup_task(docket, task_scope, task_id) - await execution.sync() - - created_at_iso = _normalize_iso_timestamp(created_at) - now_iso = datetime.now(timezone.utc).isoformat() - ttl_ms = _ttl_ms(docket) - - def build( - status: Literal[ - "working", "input_required", "completed", "failed", "cancelled" - ], - **payload: Any, - ) -> GetTaskResult: - return GetTaskResult( - task_id=task_id, - status=status, - created_at=created_at_iso, - last_updated_at=now_iso, - ttl_ms=ttl_ms, - poll_interval_ms=poll_interval_ms, - **payload, - ) - - # A logical cancellation wins over the underlying execution state: a task - # parked on input has a COMPLETED execution, so without this the branches - # below would report input_required (or completed) for a cancelled task. - if await is_cancelled(docket, task_scope, task_id): - return build("cancelled") - - if execution.state == ExecutionState.COMPLETED: - # A guard leg ends its Docket execution and records outstanding input - # requests to Redis: a completed leg with outstanding requests is the - # task waiting for tasks/update (input_required), not a finished task. - outstanding = await read_outstanding_inputs( - docket, task_scope, task_id, leg_number - ) - if outstanding: - return build("input_required", input_requests=outstanding) - raw_value = await execution.get_result(timeout=timedelta(seconds=0)) - tool = await _resolve_tool(server, base_task_key) - return build("completed", result=_inline_result(tool, raw_value)) - - if execution.state == ExecutionState.FAILED: - message = "Task failed" - error: dict[str, Any] = { - "code": mcp_types.INTERNAL_ERROR, - "message": message, - } - try: - await execution.get_result(timeout=timedelta(seconds=0)) - # On a FAILED execution, get_result re-raises the exception the task - # itself raised — an arbitrary user-defined type, so no narrower catch - # exists. Its message becomes the task's error payload; an MCPError - # already *is* a JSON-RPC error, so its code and data are preserved - # rather than flattened to an internal error. - except MCPError as protocol_error: - message = protocol_error.error.message - error = {"code": protocol_error.error.code, "message": message} - if protocol_error.error.data is not None: - error["data"] = protocol_error.error.data - except Exception as unexpected: - message = str(unexpected) - error = {"code": mcp_types.INTERNAL_ERROR, "message": message} - return build("failed", status_message=message, error=error) - - if execution.state == ExecutionState.CANCELLED: - return build("cancelled") - - status_message = None - if execution.progress and execution.progress.message: - status_message = execution.progress.message - return build("working", status_message=status_message) - - -async def tasks_update( - server: FastMCP, task_id: str, input_responses: dict[str, Any] -) -> UpdateTaskResult: - """Handle ``tasks/update``: answer a guard leg and re-enter the task. - - The responses are keyed by the surfaced keys ``tasks/get`` reported. Unknown - or already-satisfied keys are ignored (SEP-2663). When at least one answer - matches the current leg's outstanding requests, they are translated to the - tool's own keys, stored for the next leg, and a fresh Docket execution (the - next leg) is enqueued with the task's arguments. The worker is never blocked; - re-entry is the whole mechanism. A stale or empty update is an idempotent - no-op. - """ - docket = server._docket - if docket is None: - raise _task_not_found(task_id) - - task_scope = get_task_scope() - # Resolve within scope so a cross-scope update is a "not found", not a no-op. - _execution, base_task_key, leg_number, _created_at, _poll = await _lookup_task( - docket, task_scope, task_id - ) - - # Serialize concurrent updates for this task so two racing answers cannot - # each enqueue a next leg (double execution). Waiting rather than dropping - # the loser matters for partial fulfillment: SEP-2663 invites a client to - # answer a multi-request ask one key at a time, so two in-flight updates may - # carry *different* answers. Acknowledging the loser without storing its - # answer would strand the task waiting on a key the client believes it has - # already sent. Once the winner finishes, the loser re-reads the leg's - # outstanding state: a genuinely duplicate answer finds nothing left to - # match and is the idempotent no-op SEP-2663 asks for. - if not await acquire_update_lock_blocking(docket, task_scope, task_id): - # The holder is wedged. Report a retryable failure rather than a false - # acknowledgement, which would silently lose this answer. - raise MCPError( - code=mcp_types.INTERNAL_ERROR, - message=( - f"Task {task_id} has an update in progress that did not complete " - "in time; retry this update." - ), - ) - try: - # A cancelled task never re-enters: clearing outstanding on cancel makes - # translate return None already, but check explicitly so a cancel that - # races between this update's lookup and lock acquisition still wins. - if await is_cancelled(docket, task_scope, task_id): - return UpdateTaskResult() - - matched = await translate_responses( - docket, task_scope, task_id, leg_number, input_responses - ) - if matched is None: - # Nothing matched the current leg's outstanding requests: the leg was - # already answered, or the keys are unknown. Idempotent no-op. - return UpdateTaskResult() - translated, answered_keys = matched - - # Store the answers for the next leg to read. They accumulate: a client - # may answer a multi-request ask one update at a time. - await store_input_responses(docket, task_scope, task_id, translated) - - # A partial update retires only the keys it answered, leaving the task - # `input_required` with the rest surfaced; the leg re-enters only once - # every request has an answer (SEP-2663 partial fulfillment). - # - # The *last* answer deliberately leaves its marker in place. Outstanding - # requests are what make a completed-but-parked leg read as - # `input_required` rather than as a finished task, so retiring the final - # one before the next leg is durable would let a racing `tasks/get` - # report the task complete with a `None` result — and would strand it - # there for good if the enqueue below failed, since a retried update - # would no longer match any key. `clear_outstanding` runs after the - # pointer swap instead. - outstanding = await read_outstanding_inputs( - docket, task_scope, task_id, leg_number - ) - if set(outstanding) - set(answered_keys): - await discard_outstanding( - docket, task_scope, task_id, leg_number, answered_keys - ) - return UpdateTaskResult() - - # Every request is answered, so enqueue the next leg. Ordering matters: - # the answers must be in Redis before the next leg's worker context - # loads them, and current_leg must not advance to an execution that is - # not yet durable — so enqueue (with its durable wait) precedes the - # pointer swap. - component = await registered_component_for_key( - server, parse_task_key(base_task_key)["component_identifier"] - ) - raw_arguments = await load_task_args(docket, task_scope, task_id) - next_leg = leg_number + 1 - next_leg_key = leg_execution_key(base_task_key, next_leg) - - await enqueue_task_leg(server, docket, component, raw_arguments, next_leg_key) - await save_current_leg( - docket, - task_scope, - task_id, - next_leg_key, - next_leg, - _task_key_ttl_seconds(docket), - ) - # The answered leg's surfaced keys are now superseded; drop them so they - # are never reused (SEP-2663 L350). - await clear_outstanding(docket, task_scope, task_id, leg_number) - return UpdateTaskResult() - finally: - await release_update_lock(docket, task_scope, task_id) - - -async def tasks_cancel(server: FastMCP, task_id: str) -> CancelTaskResult: - """Handle ``tasks/cancel``: cooperatively cancel the task, empty ack. - - A durable cancellation marker is recorded so the logical task reports - ``cancelled`` and refuses re-entry even when it is parked on input — whose - current Docket execution is already ``COMPLETED``, making ``docket.cancel`` - on it a no-op. The current leg's outstanding requests are cleared so a - racing ``tasks/update`` naming them finds nothing, and the running - execution is still cancelled cooperatively for the ``working`` case. - - Cancellation runs under the per-task update lock and re-resolves the leg - once held, so it never cancels a stale leg while ``tasks/update`` is - concurrently enqueuing the next one: whichever wins the lock runs to - completion before the other, and the update rechecks the marker under the - same lock. If the lock is wedged past its timeout, cancel proceeds - best-effort rather than hang. - """ - docket = server._docket - if docket is None: - raise _task_not_found(task_id) - - task_scope = get_task_scope() - # Validate the task exists within scope before taking the lock. - await _lookup_task(docket, task_scope, task_id) - - got_lock = await acquire_update_lock_blocking(docket, task_scope, task_id) - try: - # Re-resolve under the lock: an update that ran first has advanced the - # current leg, so this cancels the leg that is actually live now. - execution, _base_task_key, leg_number, _created_at, _poll = await _lookup_task( - docket, task_scope, task_id - ) - ttl_seconds = int(docket.execution_ttl.total_seconds()) - await mark_cancelled(docket, task_scope, task_id, ttl_seconds) - await clear_outstanding(docket, task_scope, task_id, leg_number) - await docket.cancel(execution.key) - finally: - if got_lock: - await release_update_lock(docket, task_scope, task_id) - return CancelTaskResult() diff --git a/fastmcp_tasks/fastmcp_tasks/input_loop.py b/fastmcp_tasks/fastmcp_tasks/input_loop.py deleted file mode 100644 index f1f146c09..000000000 --- a/fastmcp_tasks/fastmcp_tasks/input_loop.py +++ /dev/null @@ -1,210 +0,0 @@ -"""The end-and-reenter capture wrapper for guard-pattern task tools. - -A guard tool asks for input by *returning* an `InputRequiredResult` rather than -awaiting `ctx.elicit()`. Foreground, each such return is one leg of a -multi-round-trip: the tool returns, the client answers, the framework re-invokes -the tool with the answers on `ctx.input_responses`. The tool body is written -once and is oblivious to how many legs it takes. - -As a background task the leg boundary is a *worker* boundary. This wrapper runs -the tool body exactly once. If the body returns a real value, it is the leg's -result. If the body returns an `InputRequiredResult`, the wrapper records the -leg's outstanding requests (and any carried `request_state`) to Redis and -returns — the Docket execution then completes and the worker is freed. The task -sits in `input_required` as durable state until the client answers via -`tasks/update`, which enqueues a fresh Docket execution (the next leg) that -re-runs this wrapper with the accumulated state injected onto `ctx`. No worker -is ever blocked awaiting input. - -The wrapper preserves the wrapped callable's signature so Docket's dependency -injection still resolves the tool's parameters (its own args, `ctx`, and any -Docket-native dependencies) exactly as it would for the raw callable. The -per-leg state (`ctx.input_responses` / `ctx.request_state`) is injected by the -worker `Context` factory (`make_task_context`) before the body runs. -""" - -from __future__ import annotations - -import functools -import inspect -import logging -from typing import TYPE_CHECKING, Any - -import mcp_types -from mcp.shared.exceptions import MCPError - -from fastmcp.exceptions import FastMCPError -from fastmcp.tools.base import InputRequiredToolResult, ToolResult -from fastmcp_tasks.context import get_task_context, get_task_leg_number -from fastmcp_tasks.input_store import store_outstanding - -if TYPE_CHECKING: - from collections.abc import Awaitable, Callable - - from docket import Docket - -logger = logging.getLogger(__name__) - - -def _as_input_required(result: Any) -> mcp_types.InputRequiredResult | None: - """Return the `InputRequiredResult` a guard leg produced, or None. - - A tool body may return the bare `InputRequiredResult` or the - `InputRequiredToolResult` wrapper FastMCP uses foreground; both mean the same - ask. - """ - if isinstance(result, InputRequiredToolResult): - return result.input_required - if isinstance(result, mcp_types.InputRequiredResult): - return result - return None - - -def _serialize_requests( - input_requests: mcp_types.InputRequests, -) -> dict[str, dict[str, Any]]: - """Dump each request to the wire payload surfaced for the client to answer.""" - return { - key: request.model_dump(by_alias=True, mode="json", exclude_none=True) - for key, request in input_requests.items() - } - - -def _resolve_docket() -> Docket | None: - """Resolve the active Docket from the current context or worker default.""" - from fastmcp.server.dependencies import get_context - from fastmcp_tasks.dependencies import _current_docket - - try: - docket = get_context().fastmcp._docket - except RuntimeError: - docket = None - if docket is None: - docket = _current_docket.get() - return docket - - -def _mask_error_details() -> bool: - """The worker server's error-masking policy, mirroring the sync call path. - - Resolves the owning server through ``get_server()`` (the worker-server - resolver) rather than ``get_context()``: a tool that raises without ever - requesting a ``ctx`` parameter has no active ``Context``, so reading the - policy off the context would silently fall back to the global default and - leak unmasked error text. - """ - import fastmcp - from fastmcp.server.dependencies import get_server - - try: - return get_server()._mask_error_details - except RuntimeError: - return fastmcp.settings.mask_error_details - - -def _error_result(tool_name: str, exc: Exception) -> ToolResult: - """An ``is_error`` result for a task tool that raised, mirroring foreground. - - A raised tool error is a *completed* task carrying an error result, never a - ``failed`` task (SEP-2663 reserves ``failed`` for protocol faults, and a live - ``tools/call`` returns the same `isError` result). A `FastMCPError` (e.g. - ``ToolError``) reaches the client verbatim, as the synchronous path re-raises - it unmasked; any other exception is masked per the server's policy. - """ - if isinstance(exc, FastMCPError): - message = str(exc) - elif _mask_error_details(): - message = f"Error calling tool {tool_name!r}" - else: - message = f"Error calling tool {tool_name!r}: {exc}" - return ToolResult( - content=[mcp_types.TextContent(type="text", text=message)], is_error=True - ) - - -def reentrant_task_fn( - fn: Callable[..., Awaitable[Any]], - tool_name: str, -) -> Callable[..., Awaitable[Any]]: - """Wrap a task tool's callable to capture a guard leg's ask (end-and-reenter). - - Signature-preserving, so Docket injects the wrapped callable's parameters - unchanged. The body runs exactly once: a real return is the leg's result; an - `InputRequiredResult` is captured to Redis (outstanding requests + carried - state) and the wrapper returns, ending the leg without blocking. The next - leg is enqueued by ``tasks/update`` when the client answers. A raised tool - error becomes a completed `is_error` result (not a failed task), matching the - synchronous `tools/call` path. - """ - - @functools.wraps(fn) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - try: - result = await fn(*args, **kwargs) - except FastMCPError as exc: - return _error_result(tool_name, exc) - except MCPError: - # A protocol fault, not a tool error. SEP-2663 reserves `failed` - # for exactly this, so it must escape the wrapper: the Docket - # execution fails and `tasks/get` inlines the JSON-RPC error - # instead of reporting a completed task with an `isError` result. - logger.exception( - "background task tool %r raised a protocol error", tool_name - ) - raise - except Exception as exc: - logger.exception("background task tool %r raised", tool_name) - return _error_result(tool_name, exc) - input_required = _as_input_required(result) - if input_required is None: - return result - - requests = input_required.input_requests or {} - request_state = input_required.request_state - if not requests: - if request_state is None: - # Asks nothing and carries nothing — terminal, not a park. - return result - # State-only round: foreground re-invokes the tool after a backoff, - # carrying `request_state` forward with no client interaction. The - # tasked path has no self-continuation for that yet, so parking it - # (with no requests for the client to answer) would strand the task. - # Fail loudly rather than silently report a wrong completed result. - return _error_result( - tool_name, - FastMCPError( - "A background task returned a state-only " - "InputRequiredResult (request_state with no input_requests). " - "Checkpoint-style rounds that carry state without asking the " - "client anything are not yet supported for tasks; include at " - "least one input request, or run the tool synchronously." - ), - ) - - task_context = get_task_context() - docket = _resolve_docket() - if task_context is None or docket is None: - logger.warning( - "guard leg produced an ask outside a task worker; returning it" - ) - return result - - await store_outstanding( - docket, - task_context.task_scope, - task_context.task_id, - get_task_leg_number(), - _serialize_requests(requests), - request_state, - ) - # The leg ends here: the Docket execution completes and the worker is - # freed. The task is now input_required until tasks/update enqueues the - # next leg. Return None so the completed leg carries no stray result. - return None - - # `functools.wraps` copies `__wrapped__`, so `inspect.signature` already - # unwraps to `fn`; set it explicitly too, so a dependency injector reading - # `__signature__` directly (rather than following `__wrapped__`) still sees - # the tool's real parameters. - wrapper.__signature__ = inspect.signature(fn) # ty: ignore[unresolved-attribute] - return wrapper diff --git a/fastmcp_tasks/fastmcp_tasks/input_store.py b/fastmcp_tasks/fastmcp_tasks/input_store.py deleted file mode 100644 index 9dab545ea..000000000 --- a/fastmcp_tasks/fastmcp_tasks/input_store.py +++ /dev/null @@ -1,551 +0,0 @@ -"""Per-task Redis state for SEP-2663 end-and-reenter input gathering. - -A background task gathers client input by *ending a leg* and re-entering, never -by blocking a worker. When a `task=True` tool returns an `InputRequiredResult`, -the leg's Docket execution completes and the worker is freed; the task's state -lives here in Redis as `input_required`. When the client answers via -`tasks/update`, a fresh Docket execution (the next leg) re-runs the tool with the -accumulated state injected onto its `Context`. No worker ever waits for input. - -This module owns the durable state each task carries between legs: - -- **args** — the original tool arguments, re-supplied to every leg. -- **current_leg / leg** — the latest leg's Docket execution key and its number. -- **request_state** — the opaque string a leg carried forward (SEP-2322). -- **input_responses** — the typed answers the last `tasks/update` delivered, - translated to the tool's own request keys. -- **input:requests / input:map** — the current leg's outstanding requests, keyed - by a server-minted surfaced key, plus the surfaced-key → tool-key mapping. - -Each surfaced request key is minted fresh with high-entropy suffix and never -reused after its response is delivered (SEP-2663 L350): a task that asks twice, -or a leg that requests several inputs at once, surfaces distinct, independently -answerable keys, and the tool reads its *own* keys on the next leg via the -translated `input_responses`. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import secrets -from typing import TYPE_CHECKING, Any, cast - -import mcp_types - -from fastmcp_tasks.keys import task_redis_prefix - -if TYPE_CHECKING: - from collections.abc import Iterable - - from docket import Docket - -logger = logging.getLogger(__name__) - -# How long a task's input state (outstanding requests and delivered responses) -# lives before expiring. With end-and-reenter no worker is held while a task is -# input_required, so this bounds only how long durable input state survives, not -# any worker slot. -INPUT_TTL_SECONDS = 3600 - -# Reconstruct a typed response from its stored `{"type": name, "data": dump}` -# form so a re-entered leg reads a real `ElicitResult` (etc.) on -# `ctx.input_responses`, matching the foreground guard contract. -_RESULT_TYPE_BY_NAME: dict[str, type[mcp_types.Result]] = { - "ElicitResult": mcp_types.ElicitResult, - "CreateMessageResult": mcp_types.CreateMessageResult, - "CreateMessageResultWithTools": mcp_types.CreateMessageResultWithTools, - "ListRootsResult": mcp_types.ListRootsResult, -} - -# Map an outstanding request's wire method to the result type its answer -# validates into. Elicitation is the supported in-task input; the others are -# kept complete so a client that answers one is parsed rather than dropped. -_RESULT_TYPE_BY_METHOD: dict[str, type[mcp_types.Result]] = { - "elicitation/create": mcp_types.ElicitResult, - "sampling/createMessage": mcp_types.CreateMessageResult, - "roots/list": mcp_types.ListRootsResult, -} - - -def result_type_for_method(method: str) -> type[mcp_types.Result]: - """The result type an outstanding request's answer validates into.""" - return _RESULT_TYPE_BY_METHOD.get(method, mcp_types.ElicitResult) - - -def _prefix(docket: Docket, task_scope: str | None, task_id: str) -> str: - return f"{task_redis_prefix(task_scope)}:{task_id}" - - -def _args_key(docket: Docket, task_scope: str | None, task_id: str) -> str: - return docket.key(f"{_prefix(docket, task_scope, task_id)}:args") - - -def _current_leg_key(docket: Docket, task_scope: str | None, task_id: str) -> str: - return docket.key(f"{_prefix(docket, task_scope, task_id)}:current_leg") - - -def _leg_number_key(docket: Docket, task_scope: str | None, task_id: str) -> str: - return docket.key(f"{_prefix(docket, task_scope, task_id)}:leg") - - -def _request_state_key(docket: Docket, task_scope: str | None, task_id: str) -> str: - return docket.key(f"{_prefix(docket, task_scope, task_id)}:request_state") - - -def _input_responses_key(docket: Docket, task_scope: str | None, task_id: str) -> str: - return docket.key(f"{_prefix(docket, task_scope, task_id)}:input_responses") - - -def _requests_key( - docket: Docket, task_scope: str | None, task_id: str, leg: int -) -> str: - """Redis hash of a leg's outstanding input requests, keyed by surfaced key. - - Scoped by leg number so a re-entered leg's fresh requests never collide with - the answered leg's stale ones in the shared keyspace. - """ - return docket.key(f"{_prefix(docket, task_scope, task_id)}:input:{leg}:requests") - - -def _map_key(docket: Docket, task_scope: str | None, task_id: str, leg: int) -> str: - """Redis hash mapping a leg's surfaced keys back to the tool's own keys.""" - return docket.key(f"{_prefix(docket, task_scope, task_id)}:input:{leg}:map") - - -def _mint_surfaced_key(task_id: str) -> str: - """Mint a unique surfaced key for one outstanding request (SEP-2663 L350). - - Namespaced by the task id and suffixed with fresh entropy so no two - requests — across legs or within one leg — ever collide, and a key is never - reused after its response is delivered. - """ - return f"{task_id}:{secrets.token_hex(8)}" - - -def _decode(value: Any) -> str | None: - if value is None: - return None - if isinstance(value, bytes): - return value.decode("utf-8") - return str(value) - - -# --------------------------------------------------------------------------- -# Task arguments and leg pointer (written at create, advanced at tasks/update) -# --------------------------------------------------------------------------- - - -async def save_task_args( - docket: Docket, - task_scope: str | None, - task_id: str, - arguments: dict[str, Any], - ttl_seconds: int, -) -> None: - """Store the original tool arguments, re-supplied to every leg.""" - async with docket.redis() as redis: - await redis.set( - _args_key(docket, task_scope, task_id), - json.dumps(arguments), - ex=ttl_seconds, - ) - - -async def load_task_args( - docket: Docket, task_scope: str | None, task_id: str -) -> dict[str, Any]: - """Load the stored tool arguments for a task's next leg.""" - async with docket.redis() as redis: - raw = await redis.get(_args_key(docket, task_scope, task_id)) - decoded = _decode(raw) - if not decoded: - return {} - parsed = json.loads(decoded) - return parsed if isinstance(parsed, dict) else {} - - -async def save_current_leg( - docket: Docket, - task_scope: str | None, - task_id: str, - leg_key: str, - leg_number: int, - ttl_seconds: int, -) -> None: - """Record the latest leg's Docket execution key and its number.""" - async with docket.redis() as redis: - await redis.set( - _current_leg_key(docket, task_scope, task_id), leg_key, ex=ttl_seconds - ) - await redis.set( - _leg_number_key(docket, task_scope, task_id), - str(leg_number), - ex=ttl_seconds, - ) - - -async def refresh_current_leg_ttl( - docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int -) -> None: - """Extend the current-leg pointer's TTL (sliding expiration). - - The pointer is written with a wall-clock TTL, but a leg's execution can run - longer than that — a resumed guard leg especially. Refreshing on each poll - keeps the routing pointer alive for an actively-polled task no matter how - long the leg runs, so ``_lookup_task`` never falls back to the base leg - while the current leg is still executing. - """ - async with docket.redis() as redis: - await redis.expire(_current_leg_key(docket, task_scope, task_id), ttl_seconds) - await redis.expire(_leg_number_key(docket, task_scope, task_id), ttl_seconds) - - -async def load_current_leg( - docket: Docket, task_scope: str | None, task_id: str -) -> tuple[str | None, int]: - """Return the current leg's execution key and number (defaults to 1).""" - async with docket.redis() as redis: - leg_key = _decode( - await redis.get(_current_leg_key(docket, task_scope, task_id)) - ) - leg_raw = _decode(await redis.get(_leg_number_key(docket, task_scope, task_id))) - try: - leg_number = int(leg_raw) if leg_raw else 1 - except ValueError: - leg_number = 1 - return leg_key, leg_number - - -# --------------------------------------------------------------------------- -# Outstanding requests (written by the capture wrapper, read by tasks/get) -# --------------------------------------------------------------------------- - - -async def store_outstanding( - docket: Docket, - task_scope: str | None, - task_id: str, - leg: int, - serialized_requests: dict[str, dict[str, Any]], - request_state: str | None, - ttl_seconds: int = INPUT_TTL_SECONDS, -) -> None: - """Persist a leg's outstanding input requests plus its carried state. - - ``serialized_requests`` maps the tool's own request keys to serialized - ``InputRequest`` payloads. Each is stored under a freshly minted surfaced - key, with the surfaced-key → tool-key mapping recorded alongside so - ``tasks/update`` can translate answers back. ``request_state`` is written - when the leg carried one and cleared otherwise, so it travels to the next - leg verbatim. - """ - requests_key = _requests_key(docket, task_scope, task_id, leg) - map_key = _map_key(docket, task_scope, task_id, leg) - state_key = _request_state_key(docket, task_scope, task_id) - - async with docket.redis() as redis: - for tool_key, payload in serialized_requests.items(): - surfaced = _mint_surfaced_key(task_id) - await redis.hset(requests_key, surfaced, json.dumps(payload)) - await redis.hset(map_key, surfaced, tool_key) - await redis.expire(requests_key, ttl_seconds) - await redis.expire(map_key, ttl_seconds) - # The answers that drove this leg have been consumed by the body that - # just parked, so drop them: responses accumulate per leg (a client may - # answer a multi-request ask one key at a time), and a stale carry-over - # would make the next leg look already-answered. - await redis.delete(_input_responses_key(docket, task_scope, task_id)) - if request_state is not None: - await redis.set(state_key, request_state, ex=ttl_seconds) - else: - await redis.delete(state_key) - - -async def read_outstanding_inputs( - docket: Docket, task_scope: str | None, task_id: str, leg: int -) -> dict[str, Any]: - """Return a leg's outstanding input requests, keyed by surfaced key. - - Empty when the leg is not waiting on input. Consumed by ``tasks/get`` to - build the ``input_required`` status and its ``inputRequests`` snapshot. - """ - async with docket.redis() as redis: - raw = await redis.hgetall(_requests_key(docket, task_scope, task_id, leg)) - outstanding: dict[str, Any] = {} - for key, value in raw.items(): - key_str = _decode(key) - value_str = _decode(value) - if key_str is None or value_str is None: - continue - try: - outstanding[key_str] = json.loads(value_str) - except json.JSONDecodeError: - continue - return outstanding - - -async def _read_outstanding_map( - docket: Docket, task_scope: str | None, task_id: str, leg: int -) -> dict[str, str]: - """Return the surfaced-key → tool-key mapping for a leg.""" - async with docket.redis() as redis: - raw = await redis.hgetall(_map_key(docket, task_scope, task_id, leg)) - mapping: dict[str, str] = {} - for key, value in raw.items(): - key_str = _decode(key) - value_str = _decode(value) - if key_str is None or value_str is None: - continue - mapping[key_str] = value_str - return mapping - - -# --------------------------------------------------------------------------- -# Responses (written by tasks/update, read by the next leg's context factory) -# --------------------------------------------------------------------------- - - -async def translate_responses( - docket: Docket, - task_scope: str | None, - task_id: str, - leg: int, - responses: dict[str, Any], -) -> tuple[dict[str, mcp_types.Result], list[str]] | None: - """Translate a ``tasks/update`` payload into typed, tool-keyed responses. - - ``responses`` is keyed by the surfaced keys the client received for ``leg``. - Unknown or already-satisfied keys are ignored (SEP-2663). Each recognized - answer is validated into the result type its request maps to and re-keyed to - the tool's own request key. Returns ``None`` when nothing matched, so the - caller can treat a stale or empty update as an idempotent no-op. - - Returns the tool-keyed answers alongside the surfaced keys they came from, - so the caller can retire exactly the answered requests and leave the rest - outstanding. - """ - outstanding = await read_outstanding_inputs(docket, task_scope, task_id, leg) - if not outstanding: - return None - mapping = await _read_outstanding_map(docket, task_scope, task_id, leg) - - translated: dict[str, mcp_types.Result] = {} - matched: list[str] = [] - for surfaced_key, raw in responses.items(): - payload = outstanding.get(surfaced_key) - if payload is None: - continue - tool_key = mapping.get(surfaced_key) - if tool_key is None: - continue - method = payload.get("method", "elicitation/create") - result_type = result_type_for_method(method) - translated[tool_key] = result_type.model_validate(raw) - matched.append(surfaced_key) - - if not translated: - return None - return translated, matched - - -async def store_input_responses( - docket: Docket, - task_scope: str | None, - task_id: str, - translated: dict[str, mcp_types.Result], - ttl_seconds: int = INPUT_TTL_SECONDS, -) -> None: - """Store translated responses for the next leg to read via ``ctx``. - - The responses are stored typed-but-serialized (``{"type", "data"}``) so the - next leg's context factory reconstructs real result objects keyed by the - tool's own request keys. - - Answers merge into whatever the leg has already collected: a client may - answer a multi-request ask one `tasks/update` at a time, and the leg only - re-enters once every request has been answered. Callers hold the per-task - update lock, so the read-modify-write cannot interleave. - """ - stored = { - tool_key: { - "type": type(result).__name__, - "data": result.model_dump(by_alias=True, mode="json"), - } - for tool_key, result in translated.items() - } - responses_key = _input_responses_key(docket, task_scope, task_id) - async with docket.redis() as redis: - existing_raw = _decode(await redis.get(responses_key)) - if existing_raw: - try: - existing = json.loads(existing_raw) - except json.JSONDecodeError: - existing = {} - if isinstance(existing, dict): - stored = {**existing, **stored} - await redis.set(responses_key, json.dumps(stored), ex=ttl_seconds) - - -async def discard_outstanding( - docket: Docket, - task_scope: str | None, - task_id: str, - leg: int, - surfaced_keys: Iterable[str], -) -> None: - """Drop just the surfaced keys an update answered, keeping the rest pending. - - Partial fulfillment (SEP-2663): a leg that asked several questions stays - ``input_required`` until all are answered, and each ``tasks/get`` in between - must surface only the still-unanswered keys. - """ - keys = list(surfaced_keys) - if not keys: - return - async with docket.redis() as redis: - await redis.hdel(_requests_key(docket, task_scope, task_id, leg), *keys) - await redis.hdel(_map_key(docket, task_scope, task_id, leg), *keys) - - -async def clear_outstanding( - docket: Docket, task_scope: str | None, task_id: str, leg: int -) -> None: - """Drop a leg's outstanding requests and mapping once it has been answered. - - The answered surfaced keys are never reused (a later leg mints its own), so - a duplicate ``tasks/update`` naming them finds nothing and is a no-op. - """ - async with docket.redis() as redis: - await redis.delete(_requests_key(docket, task_scope, task_id, leg)) - await redis.delete(_map_key(docket, task_scope, task_id, leg)) - - -def _cancelled_key(docket: Docket, task_scope: str | None, task_id: str) -> str: - return docket.key(f"{_prefix(docket, task_scope, task_id)}:cancelled") - - -async def mark_cancelled( - docket: Docket, task_scope: str | None, task_id: str, ttl_seconds: int -) -> None: - """Record that a task was cancelled at the logical (not per-leg) level. - - An ``input_required`` task's current Docket execution is already - ``COMPLETED`` — the outstanding-input record is what keeps it parked — so - ``docket.cancel`` on that execution is a no-op. This durable marker lets - ``tasks/get`` report ``cancelled`` and ``tasks/update`` refuse to resume, - regardless of the underlying execution state. Expires with the task's TTL. - """ - async with docket.redis() as redis: - await redis.set( - _cancelled_key(docket, task_scope, task_id), b"1", ex=max(1, ttl_seconds) - ) - - -async def is_cancelled(docket: Docket, task_scope: str | None, task_id: str) -> bool: - """Whether the task was logically cancelled (see ``mark_cancelled``).""" - async with docket.redis() as redis: - return bool(await redis.exists(_cancelled_key(docket, task_scope, task_id))) - - -# How long the per-task update lock lives if its holder dies mid-update. A -# generous ceiling: a single tasks/update is fast, so the lock is normally held -# for milliseconds; the TTL only guards against a crashed holder. -_UPDATE_LOCK_TTL_SECONDS = 30 - - -def _update_lock_key(docket: Docket, task_scope: str | None, task_id: str) -> str: - return docket.key(f"{_prefix(docket, task_scope, task_id)}:update_lock") - - -async def acquire_update_lock( - docket: Docket, task_scope: str | None, task_id: str -) -> bool: - """Take the per-task update lock, or return False if one is already held. - - Serializes concurrent ``tasks/update`` calls for a task so two racing - answers cannot each enqueue a next leg (double execution). A well-behaved - client polls sequentially and never contends; a loser is an idempotent - no-op, matching SEP-2663's "ignore already-satisfied" rule. - """ - async with docket.redis() as redis: - got = await redis.set( - _update_lock_key(docket, task_scope, task_id), - b"1", - nx=True, - ex=_UPDATE_LOCK_TTL_SECONDS, - ) - return bool(got) - - -async def acquire_update_lock_blocking( - docket: Docket, - task_scope: str | None, - task_id: str, - *, - timeout: float = 5.0, - poll: float = 0.02, -) -> bool: - """Wait for the per-task update lock, up to ``timeout`` seconds. - - ``tasks/cancel`` uses this to serialize with an in-flight ``tasks/update``: - it must not cancel a stale leg while an update concurrently enqueues the - next one. A single update is fast (milliseconds), so contention is brief; - returns False if the lock is still held at the deadline (a wedged holder), - letting the caller proceed best-effort rather than hang. - """ - loop = asyncio.get_event_loop() - deadline = loop.time() + timeout - while True: - if await acquire_update_lock(docket, task_scope, task_id): - return True - if loop.time() >= deadline: - return False - await asyncio.sleep(poll) - - -async def release_update_lock( - docket: Docket, task_scope: str | None, task_id: str -) -> None: - """Release the per-task update lock.""" - async with docket.redis() as redis: - await redis.delete(_update_lock_key(docket, task_scope, task_id)) - - -async def load_pending_input( - docket: Docket, task_scope: str | None, task_id: str -) -> tuple[str | None, mcp_types.InputResponses | None]: - """Load the per-leg state a re-entered leg reads via ``ctx``. - - Returns ``(request_state, input_responses)``: the opaque state carried - forward and the typed answers keyed by the tool's own request keys. Both are - ``None`` on the first leg (nothing has been asked yet). - """ - async with docket.redis() as redis: - state_raw = _decode( - await redis.get(_request_state_key(docket, task_scope, task_id)) - ) - responses_raw = _decode( - await redis.get(_input_responses_key(docket, task_scope, task_id)) - ) - - responses: dict[str, mcp_types.Result] | None = None - if responses_raw: - parsed = json.loads(responses_raw) - if isinstance(parsed, dict): - responses = {} - for tool_key, entry in parsed.items(): - if not isinstance(entry, dict): - continue - result_type = _RESULT_TYPE_BY_NAME.get(entry.get("type", "")) - if result_type is None: - continue - responses[tool_key] = result_type.model_validate(entry.get("data")) - - # The reconstructed values are the concrete result types the tool asked for; - # `InputResponses` is that union keyed by request key. The `Result` element - # type erases that for the checker, so narrow at the return. - if responses is None: - return state_raw, None - return state_raw, cast("mcp_types.InputResponses", responses) diff --git a/fastmcp_tasks/fastmcp_tasks/lifespan.py b/fastmcp_tasks/fastmcp_tasks/lifespan.py deleted file mode 100644 index a8738bd81..000000000 --- a/fastmcp_tasks/fastmcp_tasks/lifespan.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Docket lifecycle for FastMCP background tasks. - -Extracted from the SEP-1686 ``LifespanMixin._docket_lifespan`` and driven by -``TasksExtension.lifespan()``. Core's ``_extensions_lifespan`` already enters -this once per runtime tree at the root and defers on mounted children, and -``SharedContext`` plus the server ContextVar are established before extension -lifespans run — so this no longer manages either. It starts Docket and a Worker -when there are task-enabled components, registers those components' callables, -and runs the worker (with the snapshot-restore dependency) until shutdown. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager, suppress -from typing import TYPE_CHECKING, Any - -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP - from fastmcp_tasks.settings import DocketSettings - -logger = get_logger(__name__) - - -@asynccontextmanager -async def docket_lifespan( - server: FastMCP, settings: DocketSettings -) -> AsyncIterator[None]: - """Manage the Docket instance and Worker for background task execution. - - Sets ``server._docket`` / ``server._worker`` for the duration and registers - each task-enabled component's callable, then runs the worker until the - context exits. A no-op if pydocket is unavailable or the server declares no - task-enabled components. - """ - from docket import Depends, Docket, Worker - - import fastmcp - from fastmcp_tasks.components import register_component_with_docket - from fastmcp_tasks.context import restore_task_snapshot - from fastmcp_tasks.dependencies import ( - _current_docket, - _current_worker, - is_docket_available, - ) - - if not is_docket_available(): - yield - return - - try: - candidates = list(await server.get_tasks()) - except Exception as e: - logger.warning(f"Failed to collect task components: {e}") - if fastmcp.settings.mounted_components_raise_on_load_error: - raise - candidates = [] - - # get_tasks() applies server-level transforms that can inject non-task tools; - # re-filter by the actual task config (the recorded landmine). - task_components = [c for c in candidates if c.task_config.supports_tasks()] - if not task_components: - yield - return - - async with Docket(name=settings.name, url=settings.url) as docket: - server._docket = docket - for component in task_components: - register_component_with_docket(component, docket) - - docket_token = _current_docket.set(docket) - try: - worker_kwargs: dict[str, Any] = { - "concurrency": settings.concurrency, - "redelivery_timeout": settings.redelivery_timeout, - "reconnection_delay": settings.reconnection_delay, - "minimum_check_interval": settings.minimum_check_interval, - } - if settings.worker_name: - worker_kwargs["name"] = settings.worker_name - - async with Worker( - docket, - dependencies=[Depends(restore_task_snapshot)], - **worker_kwargs, - ) as worker: - server._worker = worker - worker_token = _current_worker.set(worker) - try: - worker_task = asyncio.create_task(worker.run_forever()) - try: - yield - finally: - # End-and-reenter never parks a worker on input, so a - # task waiting for input holds no worker slot: cancelling - # run_forever drains promptly regardless of task state. - worker_task.cancel() - with suppress(asyncio.CancelledError): - await worker_task - finally: - _current_worker.reset(worker_token) - server._worker = None - finally: - _current_docket.reset(docket_token) - server._docket = None diff --git a/fastmcp_tasks/fastmcp_tasks/models.py b/fastmcp_tasks/fastmcp_tasks/models.py deleted file mode 100644 index a58fde3a1..000000000 --- a/fastmcp_tasks/fastmcp_tasks/models.py +++ /dev/null @@ -1,202 +0,0 @@ -"""SEP-2663 tasks-extension wire models. - -The `io.modelcontextprotocol/tasks` extension (SEP-2663) defines its own wire -shapes, distinct from the SEP-1686 task types the MCP SDK still ships -(`mcp_types.Task` uses `ttl`/`pollInterval`; SEP-2663 uses `ttlMs`/`pollIntervalMs` -and a *flat* `CreateTaskResult` rather than a nested `{task: ...}`). These models -serialize to the SEP-2663 shapes and are validated against the vendored draft -JSON schema in the test suite. - -A note on `_meta`: the draft schema composes result shapes as -`allOf[Result, Task]`, and the `Task` arm carries `additionalProperties: false` -without listing `_meta`. A `_meta` key therefore fails schema validation on those -results. These models leave `_meta` unset and rely on the runner's -`exclude_none=True` dump to omit it, so serialized instances validate cleanly. -`ttlMs` is required-but-nullable in the schema; in practice the engine always -emits a numeric value (Docket carries a default execution TTL), so the -`exclude_none` dump never drops it. -""" - -from __future__ import annotations - -from typing import Any, Literal - -from mcp_types import RequestParams, Result -from mcp_types.jsonrpc import ( - MISSING_REQUIRED_CLIENT_CAPABILITY as _MISSING_REQUIRED_CLIENT_CAPABILITY, -) -from pydantic import BaseModel, ConfigDict, Field - -__all__ = [ - "MISSING_REQUIRED_CLIENT_CAPABILITY", - "TaskStatus", - "CreateTaskResult", - "GetTaskResult", - "UpdateTaskResult", - "CancelTaskResult", - "GetTaskParams", - "UpdateTaskParams", - "CancelTaskParams", - "GetTaskRequest", - "UpdateTaskRequest", - "CancelTaskRequest", - "missing_capability_error_data", -] - -#: JSON-RPC error code for "Missing Required Client Capability" (SEP-2663). A -#: tool whose task mode is `required` returns this when the client did not opt -#: the tasks extension in for the request, as do the `tasks/*` methods when the -#: client never negotiated the extension. Re-exported from the SDK so the code -#: tracks the protocol rather than an early draft's number. -MISSING_REQUIRED_CLIENT_CAPABILITY = _MISSING_REQUIRED_CLIENT_CAPABILITY - -TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] - - -class _TaskFields(BaseModel): - """The flat task fields shared by every SEP-2663 task result shape. - - Serializes to the schema's `Task` object (camelCase aliases, `ttlMs` - required-but-nullable). No `_meta`: the schema's `additionalProperties: - false` on the task arm forbids it (see module docstring). - """ - - # Serialization aliases: the engine constructs these by field name and the - # runner dumps them to camelCase (`model_dump(by_alias=True)`). The - # claim-production wrap returns that dump unchanged, so no input alias is - # needed. - model_config = ConfigDict(populate_by_name=True) - - task_id: str = Field(serialization_alias="taskId") - status: TaskStatus - created_at: str = Field(serialization_alias="createdAt") - last_updated_at: str = Field(serialization_alias="lastUpdatedAt") - ttl_ms: float | None = Field(serialization_alias="ttlMs") - status_message: str | None = Field( - default=None, serialization_alias="statusMessage" - ) - poll_interval_ms: float | None = Field( - default=None, serialization_alias="pollIntervalMs" - ) - - -class CreateTaskResult(_TaskFields): - """Result of an augmented `tools/call` that the server ran as a task. - - A flat merge of `Result` and `Task` (SEP-2663): the finished task stub the - client polls with `tasks/get`. Status is typically `working`. - - `resultType` is the wire discriminator that distinguishes this from a - `CallToolResult` on the shared `tools/call` method: the modern result union - carries a required `resultType`, and the SDK's client-side `ResultClaim` - for tasks requires this model to pin it to `Literal["task"]`. The vendored - draft schema omits `resultType` from the task arm (its - `additionalProperties: false` forbids it) — a schema-vs-protocol - contradiction reported upstream. Protocol interop requires the field, so we - emit it; only this shape needs it (the `tasks/*` methods each have a single - result type and bypass the discriminated union). - """ - - result_type: Literal["task"] = Field( - default="task", serialization_alias="resultType" - ) - - -class GetTaskResult(_TaskFields): - """Result of `tasks/get`: the detailed task (`Result & DetailedTask`). - - Carries exactly one of `result` (completed), `error` (failed), or - `input_requests` (input_required) alongside the flat task fields, matching - the schema's 5-status union. The three payload fields default to `None` and - are dropped from the wire dump for the statuses that do not use them. - - `resultType` is `"complete"` (SEP-2663 L338): `tasks/get` itself completes - normally, whatever the task's own status. As with `CreateTaskResult`, the - draft schema's `additionalProperties: false` omits this field — a - contradiction reported upstream; protocol interop requires emitting it. - """ - - result_type: Literal["complete"] = Field( - default="complete", serialization_alias="resultType" - ) - result: dict[str, Any] | None = None - error: dict[str, Any] | None = None - input_requests: dict[str, Any] | None = Field( - default=None, serialization_alias="inputRequests" - ) - - -class UpdateTaskResult(Result): - """Acknowledgement for `tasks/update` (SEP-2663 `Result`, `resultType: "complete"`).""" - - result_type: Literal["complete"] = Field( - default="complete", serialization_alias="resultType" - ) - - -class CancelTaskResult(Result): - """Acknowledgement for `tasks/cancel` (SEP-2663 `Result`, `resultType: "complete"`).""" - - result_type: Literal["complete"] = Field( - default="complete", serialization_alias="resultType" - ) - - -class GetTaskParams(RequestParams): - """Params for `tasks/get` / `tasks/cancel`: the target task id.""" - - model_config = ConfigDict(populate_by_name=True) - - task_id: str = Field(alias="taskId") - - -# `tasks/cancel` params are identical to `tasks/get` (just `taskId`). -CancelTaskParams = GetTaskParams - - -class UpdateTaskParams(RequestParams): - """Params for `tasks/update`: task id plus the caller's input responses.""" - - model_config = ConfigDict(populate_by_name=True) - - task_id: str = Field(alias="taskId") - input_responses: dict[str, Any] = Field(alias="inputResponses") - - -class GetTaskRequest(BaseModel): - """`tasks/get` request envelope (used by tests and clients).""" - - model_config = ConfigDict(populate_by_name=True) - - method: Literal["tasks/get"] = "tasks/get" - params: GetTaskParams - - -class UpdateTaskRequest(BaseModel): - """`tasks/update` request envelope.""" - - model_config = ConfigDict(populate_by_name=True) - - method: Literal["tasks/update"] = "tasks/update" - params: UpdateTaskParams - - -class CancelTaskRequest(BaseModel): - """`tasks/cancel` request envelope.""" - - model_config = ConfigDict(populate_by_name=True) - - method: Literal["tasks/cancel"] = "tasks/cancel" - params: GetTaskParams - - -def missing_capability_error_data() -> dict[str, Any]: - """Build the `data.requiredCapabilities` payload for a -32021 error. - - A `required`-mode tool called without the client opting the tasks extension - in for the request returns this so the client learns which capability to - declare. - """ - from fastmcp.utilities.tasks import TASKS_EXTENSION_ID - - return {"requiredCapabilities": {"extensions": {TASKS_EXTENSION_ID: {}}}} diff --git a/fastmcp_tasks/fastmcp_tasks/py.typed b/fastmcp_tasks/fastmcp_tasks/py.typed deleted file mode 100644 index 8b1378917..000000000 --- a/fastmcp_tasks/fastmcp_tasks/py.typed +++ /dev/null @@ -1 +0,0 @@ - diff --git a/fastmcp_tasks/fastmcp_tasks/settings.py b/fastmcp_tasks/fastmcp_tasks/settings.py deleted file mode 100644 index 8dcc96504..000000000 --- a/fastmcp_tasks/fastmcp_tasks/settings.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Docket worker settings for FastMCP background tasks. - -Moved out of ``fastmcp.settings`` during the SEP-1686 -> SEP-2663 migration. -The ``FASTMCP_DOCKET_*`` environment prefix is unchanged so existing -deployments keep working. ``TasksExtension`` reads this configuration (its -constructor overrides the env defaults). -""" - -from __future__ import annotations - -import inspect -import os -from datetime import timedelta -from typing import Annotated - -from pydantic import Field, SecretStr -from pydantic_settings import BaseSettings, SettingsConfigDict - -# Load the same dotenv source as core FastMCP settings, so a deployment that -# puts FASTMCP_DOCKET_* in `.env` (or a FASTMCP_ENV_FILE) configures the backend -# rather than silently falling back to memory://. -_ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") - - -class DocketSettings(BaseSettings): - """Docket worker configuration.""" - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_DOCKET_", - env_file=_ENV_FILE, - extra="ignore", - ) - - name: Annotated[ - str, - Field( - description=inspect.cleandoc( - """ - Name for the Docket queue. All servers/workers sharing the same name - and backend URL will share a task queue. - """ - ), - ), - ] = "fastmcp" - - url: Annotated[ - str, - Field( - description=inspect.cleandoc( - """ - URL for the Docket backend. Supports: - - memory:// - In-memory backend (single process only) - - redis://host:port/db - Redis/Valkey backend (distributed, multi-process) - - Example: redis://localhost:6379/0 - - Default is memory:// for single-process scenarios. Use Redis or Valkey - when coordinating tasks across multiple processes (e.g., additional - workers via the fastmcp tasks CLI). - """ - ), - ), - ] = "memory://" - - worker_name: Annotated[ - str | None, - Field( - description=inspect.cleandoc( - """ - Name for the Docket worker. If None, Docket will auto-generate - a unique worker name. - """ - ), - ), - ] = None - - concurrency: Annotated[ - int, - Field( - description=inspect.cleandoc( - """ - Maximum number of tasks the worker can process concurrently. - """ - ), - ), - ] = 10 - - redelivery_timeout: Annotated[ - timedelta, - Field( - description=inspect.cleandoc( - """ - Task redelivery timeout. If a worker doesn't complete - a task within this time, the task will be redelivered to another - worker. - """ - ), - ), - ] = timedelta(seconds=300) - - reconnection_delay: Annotated[ - timedelta, - Field( - description=inspect.cleandoc( - """ - Delay between reconnection attempts when the worker - loses connection to the Docket backend. - """ - ), - ), - ] = timedelta(seconds=5) - - minimum_check_interval: Annotated[ - timedelta, - Field( - description=inspect.cleandoc( - """ - How frequently the worker polls for new tasks. Lower - values reduce latency for task pickup at the cost of - more CPU usage. The default of 50ms is a good balance; - increase for high-volume production deployments where - tasks are long-running. - """ - ), - ), - ] = timedelta(milliseconds=50) - - -docket_settings = DocketSettings() - - -class TasksSettings(BaseSettings): - """Settings for the task engine itself, as opposed to its Docket backend.""" - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_TASKS_", - env_file=_ENV_FILE, - extra="ignore", - ) - - encryption_key: Annotated[ - SecretStr | None, - Field( - description=inspect.cleandoc( - """ - Key used to encrypt task context snapshots at rest. The snapshot - carries the submitting caller's access token and HTTP headers, - and it is written to the Docket backend for the task's TTL. - Every server and worker sharing a task queue must set the same - key; a worker that cannot decrypt a snapshot fails the task - rather than running it as an anonymous caller. When unset, the - snapshot is stored as plaintext JSON. The Fernet key is derived - from this value with PBKDF2, so any non-empty string works, but - use at least 32 random characters. - """ - ), - ), - ] = None - - -tasks_settings = TasksSettings() - - -class TasksClientSettings(BaseSettings): - """Client-side settings for driving background tasks. - - Moved here from core ``fastmcp.settings`` during the SEP-1686 -> SEP-2663 - migration: the entire client task-driving path now lives in - ``fastmcp-tasks``, so its one tunable does too. - """ - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_TASKS_CLIENT_", - env_file=_ENV_FILE, - extra="ignore", - ) - - poll_interval: Annotated[ - float, - Field( - description=inspect.cleandoc( - """ - Ceiling, in seconds, for the fallback poll backoff while the client - waits on a background task. Applies only when the server does not - advertise its own pollIntervalMs: in that case the client starts - polling fast (~20ms) and doubles up to this ceiling, so quick tasks - resolve promptly while long-running tasks don't hammer the server. - When the server advertises a pollIntervalMs, that interval is honored - exactly and this setting is ignored. Must be positive. - """ - ), - gt=0, - ), - ] = 0.5 - - -client_settings = TasksClientSettings() diff --git a/fastmcp_tasks/fastmcp_tasks/wire_production.py b/fastmcp_tasks/fastmcp_tasks/wire_production.py deleted file mode 100644 index 09ac42949..000000000 --- a/fastmcp_tasks/fastmcp_tasks/wire_production.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Server-side production of the tasks extension's claimed `tools/call` result. - -The MCP SDK ships the *consumption* half of SEP-2133 claimed results — a client -`ResultClaim` resolves an extension's result shape on a core method — but not -the *production* half: nothing lets a server emit one. On the modern protocol -the runner revalidates every `tools/call` result against -`SERVER_RESULTS[("tools/call", "2026-07-28")]`, which admits only -`CallToolResult | InputRequiredResult`. A returned `CreateTaskResult` is coerced -through those `extra="ignore"` models and stripped to nothing — the `taskId` -never reaches the client, so the tasks extension cannot create a task over the -wire even though its `tasks/*` methods (being custom methods) serialize freely. - -This module supplies the missing production half. It wraps -`mcp_types.methods.serialize_server_result` — which the runner looks up on the -module at call time — so that a modern `tools/call` result tagged -`resultType: "task"` is validated against `CreateTaskResult` and dumped as-is, -routed by the discriminator rather than the ambiguous result union (an untagged -task dict would otherwise be swallowed by the all-optional `InputRequiredResult` -arm). Every other result delegates to the original serializer unchanged. - -The wrap is process-global but inert for anything that is not a tasks server: a -server that never emits `resultType: "task"` never takes the task branch. It is -installed and reference-counted by `TasksExtension.lifespan()` so it is present -exactly while at least one tasks extension is running, and removed after the -last one stops. It is gated to modern protocol versions because claimed result -shapes exist only there. - -Removal trigger: when the SDK grows a first-class server-side claim-production -API (mirroring the client `ResultClaim`), this wrap is deleted and -`TasksExtension` declares its produced claim through that API instead. See the -upstream report in the migration notes. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -import mcp_types.methods as _methods -from mcp_types.version import MODERN_PROTOCOL_VERSIONS - -_TASK_RESULT_TYPE = "task" -_TASK_AUGMENTED_METHOD = "tools/call" - -# Sentinel distinguishing "caller passed no surface" (the runner's path, which we -# may divert) from an explicit surface another caller supplied (never diverted). -_STOCK: Any = object() - -# The original module function, captured once. `None` until the first install. -_original_serialize: Any = None -_active_holds: int = 0 - - -def _serialize_with_task_production( - method: str, - version: str, - data: Mapping[str, Any], - *, - surface: Any = _STOCK, -) -> dict[str, Any]: - """Serialize a server result, letting a tagged task result through. - - A modern `tools/call` result carrying `resultType: "task"` is returned as - the producer already dumped it, rather than being validated against — and - stripped by — the stock `CallToolResult | InputRequiredResult` surface. This - is the same bypass the runner already applies to custom-method results - (which skip surface validation entirely); the producer built this dict from - a validated `CreateTaskResult`, so its shape is already correct. Every other - result — and any call that supplies an explicit `surface` — delegates to the - SDK's original serializer unchanged. - """ - if ( - surface is _STOCK - and method == _TASK_AUGMENTED_METHOD - and version in MODERN_PROTOCOL_VERSIONS - and isinstance(data, Mapping) - and data.get("resultType") == _TASK_RESULT_TYPE - ): - return dict(data) - if surface is _STOCK: - return _original_serialize(method, version, data) - return _original_serialize(method, version, data, surface=surface) - - -def install() -> None: - """Install the task claim-production wrap (reference-counted, idempotent). - - Safe to call from every `TasksExtension.lifespan()`: the first call captures - and replaces the SDK serializer, later calls only bump the reference count. - """ - global _original_serialize, _active_holds - _active_holds += 1 - if _original_serialize is not None: - return - _original_serialize = _methods.serialize_server_result - # Runtime attribute swap: the wrapper is call-compatible (it forwards - # `surface` when supplied and only diverts the runner's no-surface task - # path), but ty cannot verify a monkeypatch's signature match. - _methods.serialize_server_result = _serialize_with_task_production # ty: ignore[invalid-assignment] - - -def uninstall() -> None: - """Release one hold; restore the SDK serializer when the last one exits.""" - global _original_serialize, _active_holds - _active_holds -= 1 - if _active_holds > 0: - return - _active_holds = 0 - if _original_serialize is not None: - _methods.serialize_server_result = _original_serialize - _original_serialize = None diff --git a/fastmcp_tasks/pyproject.toml b/fastmcp_tasks/pyproject.toml deleted file mode 100644 index 6a1de018f..000000000 --- a/fastmcp_tasks/pyproject.toml +++ /dev/null @@ -1,71 +0,0 @@ -[project] -name = "fastmcp-tasks" -dynamic = ["version", "dependencies"] -description = "Background task execution for FastMCP servers via the io.modelcontextprotocol/tasks extension (SEP-2663)." -authors = [{ name = "Jeremiah Lowin" }] - -requires-python = ">=3.10" -readme = "README.md" -license = "Apache-2.0" - -keywords = [ - "mcp", - "fastmcp tasks", - "background tasks", - "model context protocol", - "fastmcp", -] -classifiers = [ - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Typing :: Typed", -] - -[project.urls] -Homepage = "https://gofastmcp.com" -Repository = "https://github.com/PrefectHQ/fastmcp" -Documentation = "https://gofastmcp.com" - -[build-system] -requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"] -build-backend = "hatchling.build" - -[tool.hatch.version] -source = "uv-dynamic-versioning" - -[tool.hatch.metadata] -allow-direct-references = true - -[tool.hatch.build.targets.wheel] -packages = ["fastmcp_tasks"] - -[tool.uv-dynamic-versioning] -vcs = "git" -style = "pep440" -bump = true -fallback-version = "0.0.0" - -[tool.hatch.metadata.hooks.uv-dynamic-versioning] -dependencies = [ - "fastmcp-slim[server]=={{ version }}", - # Fernet and the PBKDF2 key derivation behind FASTMCP_TASKS_ENCRYPTION_KEY, - # which encrypts task context snapshots at rest. - "cryptography>=43.0.0", - "pydocket>=0.20.0", - # burner-redis 0.1.7's Windows build crashes the interpreter (native fault, - # no Python traceback) running the memory:// backend under pytest-xdist — - # reproduced on GitHub Actions windows-latest, confirmed absent on - # macOS/Linux with the same versions (full suite green there under the - # identical upgraded dependencies). pydocket only floors it at >=0.1.6, so - # capping pydocket alone is not enough: a resolver is free to pick the - # newest burner-redis satisfying that floor regardless. Pin burner-redis - # directly on Windows only (which in turn caps pydocket to <0.20.2 there, - # the last release that doesn't itself require burner-redis>=0.1.7) until - # upstream ships a fix — other platforms are unaffected and stay unpinned. - "burner-redis<0.1.7; sys_platform == 'win32'", -] diff --git a/pyproject.toml b/pyproject.toml index 27b3596b0..f8cacf956 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ azure = ["fastmcp-slim[azure]=={{ version }}"] code-mode = ["fastmcp-slim[code-mode]=={{ version }}"] gemini = ["fastmcp-slim[gemini]=={{ version }}"] openai = ["fastmcp-slim[openai]=={{ version }}"] -tasks = ["fastmcp-tasks=={{ version }}"] +tasks = ["fastmcp-slim[tasks]=={{ version }}"] [tool.uv-dynamic-versioning] vcs = "git" @@ -67,16 +67,12 @@ bump = true fallback-version = "0.0.0" [tool.uv.workspace] -members = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"] +members = ["fastmcp_slim", "fastmcp_remote"] [tool.uv] default-groups = ["dev"] exclude-newer = "1 week" -# The cooldown above refuses anything published in the last week. Exempt the -# first-party packages, whose fresh releases we install deliberately, and the -# MCP SDK, where a new major is the only version satisfying our floor and so -# has nothing older to fall back to. -exclude-newer-package = { fastmcp = false, fastmcp-slim = false, fastmcp-remote = false, prefab-ui = false, mcp = false, mcp-types = false } +exclude-newer-package = { prefab-ui = false, mcp = false, mcp-types = false, httpx2 = false, httpcore2 = false, truststore = false } [dependency-groups] dev = [ @@ -101,7 +97,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", "ruff>=0.12.8", - "ty>=0.0.59", + "ty>=0.0.55", "prek>=0.2.12", "loq>=0.1.0a3", "opentelemetry-exporter-otlp-proto-grpc>=1.39.0", @@ -112,7 +108,6 @@ dev = [ fastmcp = { workspace = true } fastmcp-slim = { workspace = true } fastmcp-remote = { workspace = true } -fastmcp-tasks = { workspace = true } [tool.pytest.ini_options] asyncio_mode = "auto" @@ -131,10 +126,9 @@ env = [ markers = [ "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", "client_process: marks tests that spawn client processes via stdio transport. These can create issues when run in the same CI environment as other subprocess-based tests.", - "subprocess_heavy: marks tests that spawn a fresh Python interpreter which imports FastMCP. Each one costs a full interpreter's memory and startup, so they run serially alongside client_process tests rather than competing with parallel xdist workers.", "conformance: marks MCP conformance tests (require Node.js/npx)", ] -pythonpath = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"] +pythonpath = ["fastmcp_slim", "fastmcp_remote"] testpaths = ["tests"] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] @@ -142,32 +136,13 @@ python_functions = ["test_*"] addopts = ["--inline-snapshot=disable"] [tool.ty.src] -include = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks", "tests", "examples"] -exclude = [ - "**/node_modules", - "**/__pycache__", - ".venv", - ".git", - "dist", - # Example subtrees excluded from the ty gate. Each either pins its own - # fastmcp (resolved against a different install) or requires a third-party - # dependency not installed in this tree. - "examples/testing_demo", # own uv.lock, targets fastmcp v1 on purpose - "examples/atproto_mcp", # needs atproto (+ its own package) - "examples/smart_home", # needs phue - "examples/apps/qr_server", # needs qrcode - "examples/providers/sqlite", # needs aiosqlite - "examples/fastmcp_config_demo", # needs pyautogui, Pillow - "examples/screenshot.py", # needs pyautogui, Pillow - "examples/memory.py", # needs asyncpg, numpy, pydantic_ai, pgvector - "examples/get_file.py", # needs aiohttp -] +include = ["fastmcp_slim", "fastmcp_remote", "tests"] +exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"] [tool.ty.environment] python-version = "3.10" [tool.ty.analysis] -# prefab_ui is the apps SDK and is not installed here. replace-imports-with-any = ["prefab_ui.**"] [tool.ty.rules] diff --git a/renovate.json b/renovate.json deleted file mode 100644 index c6f1da245..000000000 --- a/renovate.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "github>PrefectHQ/renovate-config", - "github>PrefectHQ/renovate-config:python" - ] -} diff --git a/scripts/benchmark_http_startup.py b/scripts/benchmark_http_startup.py deleted file mode 100644 index 54162ec4f..000000000 --- a/scripts/benchmark_http_startup.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python -"""Benchmark FastMCP's HTTP server cold-start path in fresh interpreters. - -The benchmark separates the work users pay before an HTTP server can accept -requests: - -1. import the public ``FastMCP`` entry point; -2. construct a server and register representative tools; -3. build the Streamable HTTP ASGI application. - -Every sample runs in a fresh interpreter. Use ratios and the shape of the -results rather than treating single-machine absolute timings as universal. - -Usage: - uv run python scripts/benchmark_http_startup.py - uv run python scripts/benchmark_http_startup.py --runs 10 - uv run python scripts/benchmark_http_startup.py --json -""" - -from __future__ import annotations - -import argparse -import json -import statistics -import subprocess -import sys -import textwrap -from collections.abc import Sequence -from typing import TypedDict - - -class Sample(TypedDict): - import_ms: float - server_ms: float - app_ms: float - total_ms: float - module_count: int - rss_mib: float - heavy_module_counts: dict[str, int] - - -_PROBE = textwrap.dedent( - """ - import json - import resource - import sys - import time - - started = time.perf_counter() - from fastmcp import FastMCP - imported = time.perf_counter() - - server = FastMCP("HTTP cold-start benchmark") - - def make_tool(index): - def tool(value: int = index) -> int: - return value - - tool.__name__ = f"tool_{index}" - return tool - - for index in range(10): - server.tool(make_tool(index)) - configured = time.perf_counter() - - app = server.http_app(transport="http", stateless_http=True) - assert app is not None - ready = time.perf_counter() - - heavy_roots = { - "authlib", - "cryptography", - "httpx2", - "key_value", - "mcp", - "mcp_types", - "opentelemetry", - "pydantic", - "rich", - "sse_starlette", - "starlette", - "uvicorn", - } - heavy_module_counts = { - root: sum( - module == root or module.startswith(f"{root}.") for module in sys.modules - ) - for root in sorted(heavy_roots) - } - heavy_module_counts = { - root: count for root, count in heavy_module_counts.items() if count - } - - print( - json.dumps( - { - "import_ms": (imported - started) * 1000, - "server_ms": (configured - imported) * 1000, - "app_ms": (ready - configured) * 1000, - "total_ms": (ready - started) * 1000, - "module_count": len(sys.modules), - "rss_mib": ( - resource.getrusage(resource.RUSAGE_SELF).ru_maxrss - / (1024 * 1024) - if sys.platform == "darwin" - else resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 - ), - "heavy_module_counts": heavy_module_counts, - } - ) - ) - """ -) - - -def _sample() -> Sample: - result = subprocess.run( - [sys.executable, "-c", _PROBE], - capture_output=True, - text=True, - check=False, - timeout=30, - ) - if result.returncode != 0: - raise RuntimeError(result.stderr) - return json.loads(result.stdout.strip().splitlines()[-1]) - - -def _median(samples: Sequence[Sample], key: str) -> float: - return statistics.median(float(sample[key]) for sample in samples) # type: ignore[literal-required] - - -def _summarize(samples: list[Sample]) -> dict[str, object]: - return { - "runs": len(samples), - "import_ms": round(_median(samples, "import_ms"), 1), - "server_ms": round(_median(samples, "server_ms"), 1), - "app_ms": round(_median(samples, "app_ms"), 1), - "total_ms": round(_median(samples, "total_ms"), 1), - "module_count": round(_median(samples, "module_count")), - "rss_mib": round(_median(samples, "rss_mib"), 1), - "heavy_module_counts": samples[-1]["heavy_module_counts"], - } - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--runs", type=int, default=5) - parser.add_argument("--json", action="store_true") - args = parser.parse_args() - - samples = [_sample() for _ in range(args.runs)] - summary = _summarize(samples) - if args.json: - print(json.dumps(summary, indent=2)) - return - - print(f"Python: {sys.version.split()[0]}") - print(f"Runs: {summary['runs']}") - print(f"Import FastMCP: {summary['import_ms']:.1f} ms") - print(f"Construct + 10 tools: {summary['server_ms']:.1f} ms") - print(f"Build HTTP app: {summary['app_ms']:.1f} ms") - print(f"Total to ASGI app: {summary['total_ms']:.1f} ms") - print(f"Modules: {summary['module_count']}") - print(f"Peak RSS: {summary['rss_mib']:.1f} MiB") - - -if __name__ == "__main__": - main() diff --git a/tests/apps/test_file_upload.py b/tests/apps/test_file_upload.py index e30646846..f8b65538b 100644 --- a/tests/apps/test_file_upload.py +++ b/tests/apps/test_file_upload.py @@ -141,9 +141,7 @@ class TestFileUploadProvider: text = result.content[0].text # type: ignore[union-attr] # ty:ignore[unresolved-attribute] assert "test.txt" in text - async def test_backend_tool_listed_as_app_only(self): - """``store_files`` is listed but declares visibility=["app"], so the - host keeps it out of the model's tool list.""" + async def test_ui_tool_visible_backend_hidden(self): server = FastMCP("test", providers=[FileUpload()]) tools = await server.list_tools() @@ -152,11 +150,7 @@ class TestFileUploadProvider: assert "file_manager" in tool_names assert "list_files" in tool_names assert "read_file" in tool_names - assert "store_files" in tool_names - - store_files = next(t for t in tools if t.name == "store_files") - assert store_files.meta is not None - assert store_files.meta["ui"]["visibility"] == ["app"] + assert "store_files" not in tool_names async def test_max_file_size_enforced_server_side(self): server = FastMCP("test", providers=[FileUpload(max_file_size=100)]) diff --git a/tests/cli/test_discovery.py b/tests/cli/test_discovery.py index 6318cc9f7..716694353 100644 --- a/tests/cli/test_discovery.py +++ b/tests/cli/test_discovery.py @@ -140,30 +140,6 @@ class TestParseMcpConfig: servers = _parse_mcp_config(path, "test") assert servers == [] - def test_invalid_server_does_not_hide_valid_servers( - self, tmp_path: Path, caplog: pytest.LogCaptureFixture - ): - path = tmp_path / "config.json" - _write_config( - path, - { - "mcpServers": { - "working": { - "command": "python", - "args": ["server.py"], - }, - "broken": { - "args": ["missing-command.py"], - }, - } - }, - ) - - servers = _parse_mcp_config(path, "test") - - assert [server.name for server in servers] == ["working"] - assert "broken" in caplog.text - def test_remote_server(self, tmp_path: Path): path = tmp_path / "config.json" _write_config(path, _REMOTE_CONFIG) @@ -172,40 +148,6 @@ class TestParseMcpConfig: assert isinstance(servers[0].config, RemoteMCPServer) assert servers[0].config.url == "http://localhost:8000/mcp" - def test_reads_as_utf8_explicitly( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - """Regression test for GH-4689: config files must be read with an - explicit UTF-8 encoding, not the platform's preferred encoding - (e.g. cp949 on Windows with a non-UTF-8 locale), since that's what - every tool that writes these files emits.""" - original_read_text = Path.read_text - - def _tracking_read_text(self: Path, *args: Any, **kwargs: Any) -> str: - assert kwargs.get("encoding") == "utf-8", ( - "path.read_text() must pass encoding='utf-8' explicitly" - ) - return original_read_text(self, *args, **kwargs) - - monkeypatch.setattr(Path, "read_text", _tracking_read_text) - - path = tmp_path / "config.json" - path.write_bytes( - json.dumps( - { - "mcpServers": { - "demo": { - "command": "echo", - "args": ["hello — world"], - } - } - } - ).encode("utf-8") - ) - servers = _parse_mcp_config(path, "test") - assert len(servers) == 1 - assert servers[0].name == "demo" - # --------------------------------------------------------------------------- # Scanner: Claude Desktop diff --git a/tests/cli/test_tasks.py b/tests/cli/test_tasks.py index 7624f025f..8da5f80c9 100644 --- a/tests/cli/test_tasks.py +++ b/tests/cli/test_tasks.py @@ -1,33 +1,9 @@ """Tests for the fastmcp tasks CLI.""" import pytest -from fastmcp_tasks.settings import DocketSettings -from fastmcp_tasks.worker_cli import ( - check_distributed_backend, - resolve_docket_settings, - tasks_app, -) -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension - - -class TestResolveDocketSettings: - """`resolve_docket_settings` reads the server's *registered* extension.""" - - def test_reads_the_registered_extensions_settings(self): - """The constructor-configured URL is visible without any env var.""" - mcp = FastMCP("t") - mcp.add_extension(TasksExtension(url="redis://example:6379/0")) - settings = resolve_docket_settings(mcp) - assert settings.url == "redis://example:6379/0" - - def test_exits_when_no_tasks_extension_registered(self): - """A server with no TasksExtension has nothing for the CLI to serve.""" - mcp = FastMCP("t") - with pytest.raises(SystemExit) as exc_info: - resolve_docket_settings(mcp) - assert exc_info.value.code == 1 +from fastmcp.cli.tasks import check_distributed_backend, tasks_app +from fastmcp.utilities.tests import temporary_settings class TestCheckDistributedBackend: @@ -35,17 +11,17 @@ class TestCheckDistributedBackend: def test_succeeds_with_redis_url(self): """Test that it succeeds with Redis URL.""" - settings = DocketSettings(url="redis://localhost:6379/0") - check_distributed_backend(settings) + with temporary_settings(docket__url="redis://localhost:6379/0"): + check_distributed_backend() def test_exits_with_helpful_error_for_memory_url(self): """Test that it exits with helpful error for memory:// URLs.""" - settings = DocketSettings(url="memory://test-123") - with pytest.raises(SystemExit) as exc_info: - check_distributed_backend(settings) + with temporary_settings(docket__url="memory://test-123"): + with pytest.raises(SystemExit) as exc_info: + check_distributed_backend() - assert isinstance(exc_info.value, SystemExit) - assert exc_info.value.code == 1 + assert isinstance(exc_info.value, SystemExit) + assert exc_info.value.code == 1 class TestWorkerCommand: diff --git a/tests/client/auth/test_client_credentials.py b/tests/client/auth/test_client_credentials.py deleted file mode 100644 index 910870fb4..000000000 --- a/tests/client/auth/test_client_credentials.py +++ /dev/null @@ -1,685 +0,0 @@ -"""Tests for machine-to-machine (M2M) client authentication. - -These cover the ``client_credentials`` grant (client_id + client_secret) and the -RFC 7523 ``private_key_jwt`` variant. Rather than standing up a real -authorization server (the in-memory server does not implement the -client_credentials grant), the tests drive the provider's ``async_auth_flow`` -directly with a mock responder that answers OAuth discovery and the token -endpoint, exactly as httpx would while running the auth flow. -""" - -import base64 -import warnings -from collections.abc import Callable -from contextlib import aclosing -from urllib.parse import urlparse - -import httpx2 -import jwt -import pytest -from key_value.aio.stores.memory import MemoryStore -from mcp.client.auth import OAuthTokenError -from mcp.shared.auth import OAuthToken - -from fastmcp.client import Client -from fastmcp.client.auth import ( - ClientCredentialsOAuthProvider, - PrivateKeyJWTOAuthProvider, - SignedJWTParameters, - static_assertion_provider, -) -from fastmcp.client.transports import SSETransport, StreamableHttpTransport - -SERVER_URL = "https://mcp.example.com/mcp" -AUTH_SERVER_URL = "https://auth.example.com" -# 32+ bytes so PyJWT does not warn about weak HMAC keys under -W error. -SIGNING_KEY = "unit-test-signing-key-padded-to-32b" - - -def make_m2m_responder( - *, - token_response: dict, - token_status: int = 200, - server_url: str = SERVER_URL, - auth_server_url: str = AUTH_SERVER_URL, - prm_scopes_supported: list[str] | None = None, -) -> tuple[Callable[[httpx2.Request], httpx2.Response], dict[str, httpx2.Request]]: - """Build a responder for the standard M2M discovery + token exchange flow. - - Returns the responder and a dict that captures the token request and the - final (retried) resource request for assertions. When ``prm_scopes_supported`` - is set, the protected-resource metadata advertises those scopes, which the SDK - flow would otherwise apply to the token request. - """ - captured: dict[str, httpx2.Request] = {} - - def responder(request: httpx2.Request) -> httpx2.Response: - url = str(request.url) - path = urlparse(url).path - header_keys = {key.lower() for key in request.headers} - - if url.startswith(server_url): - if "authorization" in header_keys: - captured["final_request"] = request - return httpx2.Response(200, text="ok") - return httpx2.Response(401, headers={"WWW-Authenticate": "Bearer"}) - - if path.startswith("/.well-known/oauth-protected-resource"): - prm: dict = { - "resource": server_url, - "authorization_servers": [auth_server_url], - } - if prm_scopes_supported is not None: - prm["scopes_supported"] = prm_scopes_supported - return httpx2.Response(200, json=prm) - - if path.startswith( - "/.well-known/oauth-authorization-server" - ) or path.startswith("/.well-known/openid-configuration"): - return httpx2.Response( - 200, - json={ - "issuer": auth_server_url, - "authorization_endpoint": f"{auth_server_url}/authorize", - "token_endpoint": f"{auth_server_url}/token", - "response_types_supported": ["code"], - }, - ) - - if url == f"{auth_server_url}/token": - captured["token_request"] = request - return httpx2.Response(token_status, json=token_response) - - raise AssertionError(f"unexpected request: {request.method} {url}") - - return responder, captured - - -async def drive_auth_flow( - provider: httpx2.Auth, - responder: Callable[[httpx2.Request], httpx2.Response], - *, - server_url: str = SERVER_URL, -) -> list[httpx2.Request]: - """Drive an httpx auth flow to completion, feeding each yield to responder.""" - requests: list[httpx2.Request] = [] - async with aclosing( - provider.async_auth_flow(httpx2.Request("POST", server_url)) - ) as flow: - sent: httpx2.Response | None = None - while True: - try: - request = await flow.asend(sent) # ty: ignore[invalid-argument-type] - except StopAsyncIteration: - break - requests.append(request) - sent = responder(request) - return requests - - -def form_body(request: httpx2.Request) -> dict[str, str]: - """Parse an x-www-form-urlencoded request body into a dict.""" - return dict(httpx2.QueryParams(request.content.decode())) - - -class TestClientCredentialsConstruction: - """Constructor ergonomics and deferred binding.""" - - def test_deferred_binding(self): - provider = ClientCredentialsOAuthProvider( - client_id="cid", client_secret="secret" - ) - assert provider._bound is False - - provider._bind(f"{SERVER_URL}/") - assert provider._bound is True - # Trailing slash is normalized away. - assert provider.context.server_url == SERVER_URL - - def test_binding_at_construction(self): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, client_id="cid", client_secret="secret" - ) - assert provider._bound is True - assert provider.context.server_url == SERVER_URL - - def test_bind_is_idempotent(self): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, client_id="cid", client_secret="secret" - ) - provider._bind("https://other.example.com/mcp") - assert provider.context.server_url == SERVER_URL - - @pytest.mark.parametrize( - "scopes, expected", - [ - (["read", "write"], "read write"), - ("read write", "read write"), - (None, None), - ], - ) - def test_scope_normalization(self, scopes, expected): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, client_id="cid", client_secret="secret", scopes=scopes - ) - assert provider.context.client_metadata.scope == expected - - def test_default_token_endpoint_auth_method(self): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, client_id="cid", client_secret="secret" - ) - assert ( - provider._fixed_client_info.token_endpoint_auth_method - == "client_secret_basic" - ) - - def test_token_endpoint_auth_method_override(self): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="cid", - client_secret="secret", - token_endpoint_auth_method="client_secret_post", - ) - assert ( - provider._fixed_client_info.token_endpoint_auth_method - == "client_secret_post" - ) - - def test_in_memory_storage_does_not_warn(self): - """M2M re-acquires tokens cheaply, so no in-memory storage warning.""" - with warnings.catch_warnings(): - warnings.simplefilter("error") - ClientCredentialsOAuthProvider( - SERVER_URL, client_id="cid", client_secret="secret" - ) - - async def test_unbound_provider_raises(self): - provider = ClientCredentialsOAuthProvider( - client_id="cid", client_secret="secret" - ) - with pytest.raises(RuntimeError, match="has no server URL"): - provider.async_auth_flow(httpx2.Request("POST", SERVER_URL)) - - -class TestClientCredentialsFlow: - """The provider discovers the token endpoint, acquires and attaches a token.""" - - async def test_acquires_and_attaches_token(self): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, client_id="cid", client_secret="secret" - ) - responder, captured = make_m2m_responder( - token_response={ - "access_token": "ACCESS123", - "token_type": "Bearer", - "expires_in": 3600, - } - ) - - requests = await drive_auth_flow(provider, responder) - - # The token exchange used the client_credentials grant. - token_body = form_body(captured["token_request"]) - assert token_body["grant_type"] == "client_credentials" - - # The retried request carries the acquired bearer token. - assert requests[-1].headers["Authorization"] == "Bearer ACCESS123" - assert captured["final_request"].headers["Authorization"] == "Bearer ACCESS123" - - async def test_client_secret_basic_uses_authorization_header(self): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="cid", - client_secret="secret", - token_endpoint_auth_method="client_secret_basic", - ) - responder, captured = make_m2m_responder( - token_response={"access_token": "T", "token_type": "Bearer"} - ) - - await drive_auth_flow(provider, responder) - - token_request = captured["token_request"] - expected = base64.b64encode(b"cid:secret").decode() - assert token_request.headers["Authorization"] == f"Basic {expected}" - # Credentials are not duplicated in the body. - assert "client_secret" not in form_body(token_request) - - async def test_client_secret_post_uses_body(self): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="cid", - client_secret="secret", - token_endpoint_auth_method="client_secret_post", - ) - responder, captured = make_m2m_responder( - token_response={"access_token": "T", "token_type": "Bearer"} - ) - - await drive_auth_flow(provider, responder) - - token_body = form_body(captured["token_request"]) - assert token_body["client_id"] == "cid" - assert token_body["client_secret"] == "secret" - assert "Authorization" not in captured["token_request"].headers - - async def test_token_error_surfaces(self): - provider = ClientCredentialsOAuthProvider( - SERVER_URL, client_id="cid", client_secret="wrong" - ) - responder, _ = make_m2m_responder( - token_response={"error": "invalid_client"}, - token_status=401, - ) - - with pytest.raises(OAuthTokenError, match="Token exchange failed"): - await drive_auth_flow(provider, responder) - - async def test_explicit_scopes_win_over_server_advertised(self): - """A caller's explicit scopes reach the token request even when the - server advertises a different set (the inherited flow would otherwise - overwrite them during 401 handling).""" - provider = ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="cid", - client_secret="secret", - scopes=["read", "write"], - ) - responder, captured = make_m2m_responder( - token_response={"access_token": "T", "token_type": "Bearer"}, - prm_scopes_supported=["admin", "superuser"], - ) - - await drive_auth_flow(provider, responder) - - token_body = form_body(captured["token_request"]) - assert token_body["scope"] == "read write" - - -class TestTokenCacheIsolation: - """Cached tokens are namespaced by client identity, not just server URL.""" - - async def test_distinct_client_ids_do_not_share_cached_tokens(self): - """Two providers with different client_ids sharing one store against the - same endpoint each retain their own token instead of clobbering one - another.""" - store = MemoryStore() - - provider_a = ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="client-a", - client_secret="secret-a", - token_storage=store, - ) - provider_b = ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="client-b", - client_secret="secret-b", - token_storage=store, - ) - - responder_a, _ = make_m2m_responder( - token_response={ - "access_token": "TOKEN-A", - "token_type": "Bearer", - "expires_in": 3600, - } - ) - responder_b, _ = make_m2m_responder( - token_response={ - "access_token": "TOKEN-B", - "token_type": "Bearer", - "expires_in": 3600, - } - ) - - await drive_auth_flow(provider_a, responder_a) - requests_b = await drive_auth_flow(provider_b, responder_b) - - # provider_b acquires and uses its own token rather than reloading the - # token provider_a wrote to the shared store. - assert requests_b[-1].headers["Authorization"] == "Bearer TOKEN-B" - - # Each client's token is preserved under its own namespace. - tokens_a = await provider_a.context.storage.get_tokens() - tokens_b = await provider_b.context.storage.get_tokens() - assert tokens_a is not None and tokens_a.access_token == "TOKEN-A" - assert tokens_b is not None and tokens_b.access_token == "TOKEN-B" - - async def test_distinct_scopes_do_not_share_cached_tokens(self): - """Two providers with the same client_id but different requested scopes - sharing one store each retain their own token: a token issued for one - scope set must not be reused for another.""" - store = MemoryStore() - - provider_read = ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="cid", - client_secret="secret", - scopes=["read"], - token_storage=store, - ) - provider_write = ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="cid", - client_secret="secret", - scopes=["write"], - token_storage=store, - ) - - responder_read, _ = make_m2m_responder( - token_response={ - "access_token": "TOKEN-READ", - "token_type": "Bearer", - "expires_in": 3600, - } - ) - responder_write, _ = make_m2m_responder( - token_response={ - "access_token": "TOKEN-WRITE", - "token_type": "Bearer", - "expires_in": 3600, - } - ) - - await drive_auth_flow(provider_read, responder_read) - requests_write = await drive_auth_flow(provider_write, responder_write) - - # The write-scoped provider acquires its own token instead of reloading - # the read-scoped token from the shared store. - assert requests_write[-1].headers["Authorization"] == "Bearer TOKEN-WRITE" - - tokens_read = await provider_read.context.storage.get_tokens() - tokens_write = await provider_write.context.storage.get_tokens() - assert tokens_read is not None and tokens_read.access_token == "TOKEN-READ" - assert tokens_write is not None and tokens_write.access_token == "TOKEN-WRITE" - - -class TestPersistentTokenExpiry: - """A token reloaded from persistent storage honors its stored expiry.""" - - @pytest.mark.parametrize("expires_in", [-100, 0]) - async def test_expired_stored_token_is_refetched(self, expires_in): - """Recreating a provider against a store holding an already-expired token - re-fetches instead of trusting the stale token as if it never expires. - - `expires_in=0` is the boundary: an immediately-expiring token still - declares an expiry, so it must not be mistaken for a non-expiring one. - """ - store = MemoryStore() - - def make_provider() -> ClientCredentialsOAuthProvider: - return ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="cid", - client_secret="secret", - scopes=["read"], - token_storage=store, - ) - - # Seed the shared store with a token whose absolute expiry is in the past. - seed_provider = make_provider() - await seed_provider.context.storage.set_tokens( - OAuthToken( - access_token="STALE-TOKEN", - token_type="Bearer", - expires_in=expires_in, - ) - ) - - provider = make_provider() - responder, _ = make_m2m_responder( - token_response={ - "access_token": "FRESH-TOKEN", - "token_type": "Bearer", - "expires_in": 3600, - } - ) - - requests = await drive_auth_flow(provider, responder) - - assert requests[-1].headers["Authorization"] == "Bearer FRESH-TOKEN" - - async def test_nonexpiring_token_ignores_stale_stored_expiry(self): - """A reloaded token without `expires_in` is non-expiring and must not - inherit a stale expiry left by a previous token it replaced.""" - store = MemoryStore() - - def make_provider() -> ClientCredentialsOAuthProvider: - return ClientCredentialsOAuthProvider( - SERVER_URL, - client_id="cid", - client_secret="secret", - scopes=["read"], - token_storage=store, - ) - - # Record a stale past expiry, then replace the token with a non-expiring - # one — set_tokens leaves the earlier expiry record in place. - seed = make_provider() - await seed.context.storage.set_tokens( - OAuthToken(access_token="OLD", token_type="Bearer", expires_in=-100) - ) - await seed.context.storage.set_tokens( - OAuthToken(access_token="NONEXPIRING", token_type="Bearer") - ) - - provider = make_provider() - responder, _ = make_m2m_responder( - token_response={ - "access_token": "FRESH-TOKEN", - "token_type": "Bearer", - "expires_in": 3600, - } - ) - - requests = await drive_auth_flow(provider, responder) - - # The non-expiring token is used as-is; the stale expiry does not force - # a needless re-exchange to FRESH-TOKEN. - assert requests[-1].headers["Authorization"] == "Bearer NONEXPIRING" - - -class TestStepUpScopeAccumulation: - """A 403 insufficient_scope step-up unions the challenged scope with the - caller's scopes instead of dropping the accumulated grant.""" - - async def test_step_up_requests_union_of_scopes(self): - token_scopes: list[str] = [] - require_write = False - - def responder(request: httpx2.Request) -> httpx2.Response: - url = str(request.url) - path = urlparse(url).path - - if url.startswith(SERVER_URL): - auth = request.headers.get("Authorization", "") - if not auth: - return httpx2.Response(401, headers={"WWW-Authenticate": "Bearer"}) - granted = auth.removeprefix("Bearer ").split() - # Once the server begins demanding "write", a token lacking it is - # challenged for step-up rather than accepted. - if require_write and "write" not in granted: - return httpx2.Response( - 403, - headers={ - "WWW-Authenticate": ( - 'Bearer error="insufficient_scope", scope="write"' - ) - }, - ) - return httpx2.Response(200, text="ok") - - if path.startswith("/.well-known/oauth-protected-resource"): - return httpx2.Response( - 200, - json={ - "resource": SERVER_URL, - "authorization_servers": [AUTH_SERVER_URL], - }, - ) - - if path.startswith( - "/.well-known/oauth-authorization-server" - ) or path.startswith("/.well-known/openid-configuration"): - return httpx2.Response( - 200, - json={ - "issuer": AUTH_SERVER_URL, - "authorization_endpoint": f"{AUTH_SERVER_URL}/authorize", - "token_endpoint": f"{AUTH_SERVER_URL}/token", - "response_types_supported": ["code"], - }, - ) - - if url == f"{AUTH_SERVER_URL}/token": - scope = form_body(request).get("scope", "") - token_scopes.append(scope) - return httpx2.Response( - 200, - json={ - "access_token": scope or "noscope", - "token_type": "Bearer", - "expires_in": 3600, - }, - ) - - raise AssertionError(f"unexpected request: {request.method} {url}") - - provider = ClientCredentialsOAuthProvider( - SERVER_URL, client_id="cid", client_secret="secret", scopes=["read"] - ) - - # Initial acquisition requests exactly the caller's scopes. - await drive_auth_flow(provider, responder) - assert token_scopes[0] == "read" - - # The server now requires an additional scope for the operation. - require_write = True - await drive_auth_flow(provider, responder) - - # The step-up token request carries the union, not just the caller's scope. - assert set(token_scopes[-1].split()) == {"read", "write"} - - -class TestPrivateKeyJWTFlow: - """private_key_jwt builds a client assertion and attaches the token.""" - - async def test_signed_assertion_flow(self): - jwt_params = SignedJWTParameters( - issuer="cid", - subject="cid", - signing_key=SIGNING_KEY, - signing_algorithm="HS256", - ) - provider = PrivateKeyJWTOAuthProvider( - SERVER_URL, - client_id="cid", - assertion_provider=jwt_params.create_assertion_provider(), - ) - responder, captured = make_m2m_responder( - token_response={ - "access_token": "JWT-ACCESS", - "token_type": "Bearer", - "expires_in": 3600, - } - ) - - requests = await drive_auth_flow(provider, responder) - - token_body = form_body(captured["token_request"]) - assert token_body["grant_type"] == "client_credentials" - assert ( - token_body["client_assertion_type"] - == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - ) - - # The assertion is a JWT whose audience is the authorization server issuer. - claims = jwt.decode( - token_body["client_assertion"], options={"verify_signature": False} - ) - assert claims["iss"] == "cid" - assert claims["sub"] == "cid" - # RFC 7523bis: the assertion audience is the auth server's issuer. - assert claims["aud"] == AUTH_SERVER_URL - - assert requests[-1].headers["Authorization"] == "Bearer JWT-ACCESS" - - async def test_static_assertion_flow(self): - prebuilt = jwt.encode( - {"iss": "cid", "sub": "cid", "aud": "anything"}, - SIGNING_KEY, - algorithm="HS256", - ) - provider = PrivateKeyJWTOAuthProvider( - SERVER_URL, - client_id="cid", - assertion_provider=static_assertion_provider(prebuilt), - ) - responder, captured = make_m2m_responder( - token_response={"access_token": "S", "token_type": "Bearer"} - ) - - await drive_auth_flow(provider, responder) - - token_body = form_body(captured["token_request"]) - assert token_body["client_assertion"] == prebuilt - - async def test_unbound_provider_raises(self): - provider = PrivateKeyJWTOAuthProvider( - client_id="cid", - assertion_provider=static_assertion_provider("token"), - ) - with pytest.raises(RuntimeError, match="has no server URL"): - provider.async_auth_flow(httpx2.Request("POST", SERVER_URL)) - - -class TestTransportIntegration: - """Providers slot into a transport's ``auth=`` and bind to the URL.""" - - def test_streamable_http_transport_binds_client_credentials(self): - provider = ClientCredentialsOAuthProvider( - client_id="cid", client_secret="secret" - ) - transport = StreamableHttpTransport(SERVER_URL, auth=provider) - assert transport.auth is provider - assert provider._bound is True - assert provider.context.server_url == SERVER_URL - - def test_sse_transport_binds_private_key_jwt(self): - provider = PrivateKeyJWTOAuthProvider( - client_id="cid", - assertion_provider=static_assertion_provider("token"), - ) - transport = SSETransport(SERVER_URL, auth=provider) - assert transport.auth is provider - assert provider._bound is True - - def test_client_binds_provider_from_url(self): - provider = ClientCredentialsOAuthProvider( - client_id="cid", client_secret="secret" - ) - Client(SERVER_URL, auth=provider) - assert provider._bound is True - assert provider.context.server_url == SERVER_URL - - -def test_assertion_provider_signs_expected_audience(): - """SignedJWTParameters produces an assertion bound to the given audience.""" - jwt_params = SignedJWTParameters( - issuer="cid", - subject="cid", - signing_key=SIGNING_KEY, - signing_algorithm="HS256", - ) - provider = jwt_params.create_assertion_provider() - - async def _run(): - return await provider("https://issuer.example.com") - - import anyio - - assertion = anyio.run(_run) - # The assertion is a JWT whose audience is exactly the requested issuer. - claims = jwt.decode(assertion, options={"verify_signature": False}) - assert claims["aud"] == "https://issuer.example.com" diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py index 22ca63af0..f538a89ca 100644 --- a/tests/client/auth/test_oauth_client.py +++ b/tests/client/auth/test_oauth_client.py @@ -3,7 +3,6 @@ import time from unittest.mock import patch from urllib.parse import urlparse -import anyio import httpx2 import pytest from key_value.aio.stores.memory import MemoryStore @@ -96,26 +95,15 @@ async def test_unauthorized(client_unauthorized: Client): SDK v2 surfaces the server's 401 as an MCPError ("Server returned an error response") rather than re-raising the raw httpx2.HTTPStatusError. """ - with pytest.raises(MCPError, match="error response") as exc_info: + with pytest.raises(MCPError, match="error response"): async with client_unauthorized: pass - assert exc_info.value.__cause__ is not exc_info.value - -async def test_ping(streamable_http_server: str): - """Test that we can ping the server. - - Pinned to legacy: `ping` is a handshake-era request removed from the modern - (2026-07-28) protocol. - """ - client = Client( - transport=StreamableHttpTransport(streamable_http_server), - auth=HeadlessOAuth(mcp_url=streamable_http_server, scopes=["read", "write"]), - mode="legacy", - ) - async with client: - assert await client.ping() +async def test_ping(client_with_headless_oauth: Client): + """Test that we can ping the server.""" + async with client_with_headless_oauth: + assert await client_with_headless_oauth.ping() async def test_list_tools(client_with_headless_oauth: Client): @@ -179,12 +167,9 @@ async def test_expired_dynamic_registration_is_retried(): server = FastMCP("TestServer", auth=provider) async with run_server_async(server, port=port, transport="http") as url: - # Pinned to legacy: `ping` is a handshake-era request removed from the - # modern (2026-07-28) protocol; the retry is exercised via the handshake. client = Client( transport=StreamableHttpTransport(url), auth=HeadlessOAuth(mcp_url=url), - mode="legacy", ) async with client: assert await client.ping() @@ -192,44 +177,6 @@ async def test_expired_dynamic_registration_is_retried(): assert provider.registration_count == 2 -async def test_oauth_callback_handler_propagates_iss_to_authorization_code_result(): - """RFC 9207: `OAuth.callback_handler()` (the production, non-headless path) - must carry `iss` from the callback query string all the way into the - `AuthorizationCodeResult` handed back to the MCP SDK. - - The MCP SDK's `validate_authorization_response_iss` raises when the - authorization server metadata advertises - `authorization_response_iss_parameter_supported` and the result it - receives has no `iss` -- so if this hop drops it, every production OAuth - login against an RFC 9207-compliant server (like OAuthProxy) fails, even - though the server sent `iss` correctly. `HeadlessOAuth` already carries - `iss` through for tests -- this test exercises the real `OAuth` class - that production clients actually use. - """ - oauth = OAuth(mcp_url="http://127.0.0.1:9999") - - async def send_callback(): - await anyio.sleep(0.1) - async with httpx2.AsyncClient() as client: - response = await client.get( - f"http://{oauth._callback_host}:{oauth.redirect_port}/callback", - params={ - "code": "auth-code-123", - "state": "state-xyz", - "iss": "https://issuer.example.com", - }, - ) - assert response.status_code == 200 - - async with anyio.create_task_group() as tg: - tg.start_soon(send_callback) - result = await oauth.callback_handler() - - assert result.code == "auth-code-123" - assert result.state == "state-xyz" - assert result.iss == "https://issuer.example.com" - - class TestOAuthClientUrlHandling: """Tests for OAuth client URL handling (issue #2573).""" diff --git a/tests/client/auth/test_oauth_static_client.py b/tests/client/auth/test_oauth_static_client.py index 9682c6d0a..1fbae5c8e 100644 --- a/tests/client/auth/test_oauth_static_client.py +++ b/tests/client/auth/test_oauth_static_client.py @@ -221,7 +221,6 @@ class TestStaticClientE2E: async with Client( transport=StreamableHttpTransport(url), auth=oauth, - mode="legacy", # `ping` is a handshake-era request ) as client: assert await client.ping() tools = await client.list_tools() diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py index 0db2a7fa9..86355ce9e 100644 --- a/tests/client/client/test_client.py +++ b/tests/client/client/test_client.py @@ -13,12 +13,12 @@ from pydantic import AnyUrl import fastmcp from fastmcp.client import Client +from fastmcp.client.tasks import TaskNotificationHandler from fastmcp.client.transports import ( ClientTransport, FastMCPTransport, ) from fastmcp.server.server import FastMCP -from tests.conftest import user_meta async def test_list_tools(fastmcp_server): @@ -273,14 +273,7 @@ async def test_client_serialization_error(): async def test_server_deserialization_error(): - """Test server error when JSON string cannot be converted to expected type. - - `_on_get_prompt` in fastmcp_slim/fastmcp/server/mixins/mcp_operations.py - catches `FastMCPError` broadly and translates it into an `MCPError` via - `to_mcp_error`, the same way `_on_call_tool` surfaces tool errors. The - `PromptError` raised during argument conversion reaches the client with - its message intact on both protocol eras. - """ + """Test server error when JSON string cannot be converted to expected type.""" server = FastMCP("TestServer") @@ -348,7 +341,7 @@ async def test_read_resource_mcp(fastmcp_server): async def test_client_connection(fastmcp_server): """Test that connect is idempotent.""" - client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy") + client = Client(transport=FastMCPTransport(fastmcp_server)) # Connect idempotently async with client: @@ -360,7 +353,7 @@ async def test_client_connection(fastmcp_server): async def test_initialize_called_once(fastmcp_server): """Test that initialization is called once and sets initialize_result.""" - client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy") + client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: # Verify that initialization succeeded by checking initialize_result assert client.initialize_result is not None @@ -369,7 +362,7 @@ async def test_initialize_called_once(fastmcp_server): async def test_initialize_result_connected(fastmcp_server): """Test that initialize_result returns the correct result when connected.""" - client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy") + client = Client(transport=FastMCPTransport(fastmcp_server)) # Initialize result should be None before connection assert client.initialize_result is None @@ -404,7 +397,7 @@ async def test_server_info_custom_version(): """Test that custom version is properly set in serverInfo.""" # Test with custom version server_with_version = FastMCP("CustomVersionServer", version="1.2.3") - client = Client(transport=FastMCPTransport(server_with_version), mode="legacy") + client = Client(transport=FastMCPTransport(server_with_version)) async with client: result = client.initialize_result @@ -414,7 +407,7 @@ async def test_server_info_custom_version(): # Test without version (backward compatibility) server_without_version = FastMCP("DefaultVersionServer") - client = Client(transport=FastMCPTransport(server_without_version), mode="legacy") + client = Client(transport=FastMCPTransport(server_without_version)) async with client: result = client.initialize_result @@ -849,7 +842,7 @@ async def test_client_unwraps_result_using_meta(): result = await client.call_tool("list_tool", {}) assert result.structured_content == {"result": [1, 2, 3]} assert result.data == [1, 2, 3] - assert user_meta(result.meta) == {"fastmcp": {"wrap_result": True}} + assert result.meta == {"fastmcp": {"wrap_result": True}} async def test_client_does_not_unwrap_dict_result(): @@ -865,7 +858,7 @@ async def test_client_does_not_unwrap_dict_result(): result = await client.call_tool("dict_tool", {}) assert result.structured_content == {"a": 1} assert result.data == {"a": 1} - assert user_meta(result.meta) is None + assert result.meta is None async def test_client_list_dict_return_type(): @@ -886,19 +879,32 @@ async def test_client_list_dict_return_type(): assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}] -def test_client_new_preserves_internal_task_extension(fastmcp_server): - """Client.new() rebuilds the clone with the auto-registered tasks claim. - - The tasks client extension (from fastmcp-tasks, imported above) is folded into - every Client automatically; a clone must carry it too so tasked calls still - resolve transparently on the clone. - """ - from fastmcp_tasks.client_models import ClientCreateTaskResult - +def test_client_new_resets_mutable_task_state(fastmcp_server): + """Client.new() should not share mutable task tracking structures.""" client = Client(transport=FastMCPTransport(fastmcp_server)) - assert ClientCreateTaskResult in client._claim_by_model + + client._task_registry["task-1"] = lambda: None # type: ignore[assignment] # ty:ignore[invalid-assignment] + client._submitted_task_ids.add("task-1") clone = client.new() + assert clone is not client - assert ClientCreateTaskResult in clone._claim_by_model - assert clone._claim_by_model is not client._claim_by_model + assert clone._task_registry == {} + assert clone._submitted_task_ids == set() + assert clone._task_registry is not client._task_registry + assert clone._submitted_task_ids is not client._submitted_task_ids + + +def test_client_new_rebinds_default_task_notification_handler(fastmcp_server): + """Client.new() should bind the default task handler to the cloned client.""" + client = Client(transport=FastMCPTransport(fastmcp_server)) + + handler = client._session_kwargs.get("message_handler") + assert isinstance(handler, TaskNotificationHandler) + + clone = client.new() + + clone_handler = clone._session_kwargs.get("message_handler") + assert isinstance(clone_handler, TaskNotificationHandler) + assert clone_handler is not handler + assert clone_handler._client_ref() is clone diff --git a/tests/client/client/test_error_handling.py b/tests/client/client/test_error_handling.py index 96e85d56d..aa1ed61d1 100644 --- a/tests/client/client/test_error_handling.py +++ b/tests/client/client/test_error_handling.py @@ -1,26 +1,17 @@ -"""Client error handling tests. - -Resource, resource-template, and prompt error *detail* surfacing is -era-neutral. `_on_read_resource` / `_on_get_prompt` in -`fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` catch `FastMCPError` -broadly and translate it into an `MCPError` via `to_mcp_error`, mirroring how -`_on_call_tool` returns tool errors as an `isError` `CallToolResult`. The -detailed message (a `ResourceError`/`PromptError`, or the `ResourceError`/ -`PromptError` that wraps an arbitrary handler exception) reaches the client -on the default `auto` mode exactly as it does on `mode="legacy"`. -""" +"""Client error handling tests.""" import logging import mcp_types import pytest -from mcp_types import TextContent +from mcp_types import TextContent, ToolUseContent from pydantic import AnyUrl from fastmcp.client import Client from fastmcp.client.mixins.tools import _parse_call_tool_result from fastmcp.client.transports import FastMCPTransport from fastmcp.exceptions import PromptError, ResourceError, ToolError +from fastmcp.server.sampling.run import SamplingTool, execute_tools from fastmcp.server.server import FastMCP @@ -414,3 +405,38 @@ class TestLogLevel: and record.levelname == "ERROR" for record in caplog.records ) + + async def test_sampling_tool_error_with_custom_log_level(self, caplog): + """ToolError with custom log_level in sampling should log at specified level.""" + + async def custom_level_sampling_tool(x: int) -> int: + raise ToolError("Expected sampling error", log_level=logging.WARNING) + + tool = SamplingTool.from_function(custom_level_sampling_tool) + tool_use = ToolUseContent( + type="tool_use", + id="test-id", + name="custom_level_sampling_tool", + input={"x": 42}, + ) + + with caplog.at_level(logging.WARNING): + results = await execute_tools( + tool_calls=[tool_use], + tool_map={"custom_level_sampling_tool": tool}, + mask_error_details=False, + ) + + assert len(results) == 1 + assert results[0].is_error + assert "Expected sampling error" in results[0].content[0].text # type: ignore + assert any( + "Error calling sampling tool" in record.message + and record.levelname == "WARNING" + for record in caplog.records + ) + assert not any( + "Error calling sampling tool" in record.message + and record.levelname == "ERROR" + for record in caplog.records + ) diff --git a/tests/client/client/test_initialize.py b/tests/client/client/test_initialize.py index 7106806ae..7bbe3aeb8 100644 --- a/tests/client/client/test_initialize.py +++ b/tests/client/client/test_initialize.py @@ -9,7 +9,7 @@ class TestInitialize: async def test_auto_initialize_default(self, fastmcp_server): """Test that auto_initialize=True is the default and works automatically.""" - client = Client(fastmcp_server, mode="legacy") + client = Client(fastmcp_server) async with client: # Should be automatically initialized @@ -19,7 +19,7 @@ class TestInitialize: async def test_auto_initialize_explicit_true(self, fastmcp_server): """Test explicit auto_initialize=True.""" - client = Client(fastmcp_server, mode="legacy", auto_initialize=True) + client = Client(fastmcp_server, auto_initialize=True) async with client: assert client.initialize_result is not None @@ -27,7 +27,7 @@ class TestInitialize: async def test_auto_initialize_false(self, fastmcp_server): """Test that auto_initialize=False prevents automatic initialization.""" - client = Client(fastmcp_server, mode="legacy", auto_initialize=False) + client = Client(fastmcp_server, auto_initialize=False) async with client: # Should not be automatically initialized @@ -35,7 +35,7 @@ class TestInitialize: async def test_manual_initialize(self, fastmcp_server): """Test manual initialization when auto_initialize=False.""" - client = Client(fastmcp_server, mode="legacy", auto_initialize=False) + client = Client(fastmcp_server, auto_initialize=False) async with client: # Manually initialize @@ -47,7 +47,7 @@ class TestInitialize: async def test_initialize_idempotent(self, fastmcp_server): """Test that calling initialize() multiple times returns cached result.""" - client = Client(fastmcp_server, mode="legacy", auto_initialize=False) + client = Client(fastmcp_server, auto_initialize=False) async with client: result1 = await client.initialize() @@ -66,7 +66,7 @@ class TestInitialize: def greet(name: str) -> str: return f"Hello, {name}!" - client = Client(server, mode="legacy") + client = Client(server) async with client: result = client.initialize_result @@ -75,7 +75,7 @@ class TestInitialize: async def test_initialize_timeout_custom(self, fastmcp_server): """Test custom timeout for initialize().""" - client = Client(fastmcp_server, mode="legacy", auto_initialize=False) + client = Client(fastmcp_server, auto_initialize=False) async with client: # Should succeed with reasonable timeout @@ -84,7 +84,7 @@ class TestInitialize: async def test_initialize_property_after_auto_init(self, fastmcp_server): """Test accessing initialize_result property after auto-initialization.""" - client = Client(fastmcp_server, mode="legacy", auto_initialize=True) + client = Client(fastmcp_server, auto_initialize=True) async with client: # Access via property @@ -98,14 +98,14 @@ class TestInitialize: async def test_initialize_property_before_connect(self, fastmcp_server): """Test that initialize_result property is None before connection.""" - client = Client(fastmcp_server, mode="legacy") + client = Client(fastmcp_server) # Not yet connected assert client.initialize_result is None async def test_manual_initialize_can_call_tools(self, fastmcp_server): """Test that manually initialized client can call tools.""" - client = Client(fastmcp_server, mode="legacy", auto_initialize=False) + client = Client(fastmcp_server, auto_initialize=False) async with client: await client.initialize() diff --git a/tests/client/client/test_mode_negotiation.py b/tests/client/client/test_mode_negotiation.py index 97c1fb3a8..fc0b14f62 100644 --- a/tests/client/client/test_mode_negotiation.py +++ b/tests/client/client/test_mode_negotiation.py @@ -4,36 +4,25 @@ FastMCP serves both protocol eras from one server object over the in-memory stream loop (``serve_dual_era_loop``), so a single ``fastmcp_server`` fixture can be driven legacy or modern by varying ``mode=`` alone: -* ``mode="auto"`` (the default) probes ``server/discover`` and negotiates the - modern era, denylist-falling-back to the initialize handshake for any server - that is not positive evidence of a modern peer. -* ``mode="legacy"`` runs the initialize handshake and reports the handshake-era - version, byte-identically to pre-v4 behavior. +* ``mode="legacy"`` (the current default) runs the initialize handshake and + reports the handshake-era version, byte-identically to pre-v4 behavior. +* ``mode="auto"`` probes ``server/discover`` and negotiates the modern era. * ``mode="2026-07-28"`` pins the modern version and adopts a synthesized ``DiscoverResult`` without a probe. """ from __future__ import annotations -import contextlib -from collections.abc import AsyncIterator -from typing import Any - import pytest -from mcp import ClientSession -from mcp.shared.exceptions import MCPError -from mcp_types import METHOD_NOT_FOUND, DiscoverResult, ServerCapabilities from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION -from typing_extensions import Unpack from fastmcp import Client, FastMCP -from fastmcp.client.transports import FastMCPTransport, SessionKwargs class TestModeValidation: - def test_default_mode_is_auto(self, fastmcp_server): - """The default is 'auto': probe server/discover, fall back to the handshake.""" - assert Client(fastmcp_server).mode == "auto" + def test_default_mode_is_legacy(self, fastmcp_server): + """The conservative default is 'legacy' (see the v4 phasing note).""" + assert Client(fastmcp_server).mode == "legacy" @pytest.mark.parametrize("mode", ["legacy", "auto", LATEST_MODERN_VERSION]) def test_valid_modes_accepted(self, fastmcp_server, mode): @@ -58,6 +47,12 @@ class TestLegacyMode: assert client.initialize_result.server_info.name == "TestServer" assert client.server_capabilities is not None + async def test_default_matches_legacy(self, fastmcp_server): + """Omitting mode= is byte-identical to mode='legacy'.""" + async with Client(fastmcp_server) as client: + assert client.protocol_version == LATEST_HANDSHAKE_VERSION + assert client.initialize_result is not None + async def test_legacy_call_tool(self, fastmcp_server): async with Client(fastmcp_server, mode="legacy") as client: result = await client.call_tool("add", {"a": 2, "b": 3}) @@ -78,18 +73,14 @@ class TestAutoMode: result = await client.call_tool("add", {"a": 4, "b": 5}) assert result.data == 9 - async def test_default_matches_auto(self, fastmcp_server): - """Omitting mode= is identical to mode='auto': modern via server/discover.""" - async with Client(fastmcp_server) as client: - assert client.protocol_version == LATEST_MODERN_VERSION - assert client.initialize_result is None + async def test_auto_falls_back_to_legacy_for_handshake_only_server(self): + """A server that only speaks the handshake era makes auto denylist-fall-back to + initialize, which still populates the InitializeResult. - async def test_auto_reaches_modern_for_dual_era_server(self): - """FastMCP always serves both eras, so auto reaches modern here. - - The fallback denylist itself is exercised by the SDK's own - ``negotiate_auto`` suite; this cell documents the FastMCP-observable - outcome for a plain server. + FastMCP always serves both eras, so this is characterized against the + real dual-era server: auto reaches modern here. The fallback denylist + itself is exercised by the SDK's own ``negotiate_auto`` suite; this cell + documents the FastMCP-observable outcome. """ mcp = FastMCP("both-eras") @@ -100,169 +91,14 @@ class TestAutoMode: async with Client(mcp, mode="auto") as client: assert client.protocol_version == LATEST_MODERN_VERSION - async def test_auto_falls_back_cleanly_when_discover_is_rejected( - self, fastmcp_server - ): - """A server that rejects the server/discover probe with a JSON-RPC error - (e.g. a non-FastMCP legacy server that doesn't implement discover) makes - auto fall back to the initialize handshake, cleanly — no error surfaces - and the legacy InitializeResult is populated. - - This characterizes the FastMCP-observable outcome of the SDK's - denylist fallback (`negotiate_auto`): every RPC error except a - disjoint modern-only -32022 falls back to `initialize()`. - """ - - class _DiscoverRejectingTransport(FastMCPTransport): - """Wraps the in-memory transport but rejects server/discover.""" - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - async with super().connect_session(**session_kwargs) as session: - - async def _reject_discover(version: str) -> dict[str, Any]: - raise MCPError( - code=METHOD_NOT_FOUND, message="Method not found" - ) - - session.send_discover = _reject_discover # ty: ignore[invalid-assignment] - yield session - - transport = _DiscoverRejectingTransport(fastmcp_server) - async with Client(transport, mode="auto") as client: - # Fell back to the handshake: legacy version + populated InitializeResult. - assert client.protocol_version == LATEST_HANDSHAKE_VERSION - assert client.initialize_result is not None - result = await client.call_tool("add", {"a": 1, "b": 2}) - assert result.data == 3 - - async def test_auto_uses_legacy_on_legacy_only_transport(self, fastmcp_server): - """A `legacy_only` transport (e.g. SSE) negotiates the handshake under auto. - - SSE cannot serve the sessionless modern era, so a client with the default - `mode="auto"` must run the initialize handshake directly rather than - probing server/discover (which the FastMCP server answers even over SSE - but then cannot serve). - """ - - class _LegacyOnlyTransport(FastMCPTransport): - legacy_only = True - - transport = _LegacyOnlyTransport(fastmcp_server) - async with Client(transport, mode="auto") as client: - assert client.protocol_version == LATEST_HANDSHAKE_VERSION - assert client.initialize_result is not None - - -class TestNonConformantModernPeer: - """A peer that answers ``server/discover`` but cannot actually serve the era. - - ``negotiate_auto`` accepts a probe that parses as the version-free - ``DiscoverResult``, where ``resultType``/``ttlMs``/``cacheScope`` all carry - SDK-side defaults. Every request after adoption is checked against the strict - per-version surface, where those three fields are required. Left alone, a - server that omits them on ``server/discover`` passes the probe and then fails - every subsequent call, so ``auto`` would adopt an era the peer cannot serve. - - GitHub's remote MCP server is a live example: it has adopted the SEP-2549 - cache fields but not result tagging, so it answers ``server/discover`` - with ``ttlMs``/``cacheScope`` and no ``resultType``. - """ - - @staticmethod - def _discover_body(**envelope: Any) -> dict[str, Any]: - return { - "supportedVersions": [LATEST_MODERN_VERSION], - "capabilities": {"tools": {}, "resources": {}, "prompts": {}}, - "serverInfo": {"name": "TestServer", "version": "1.0"}, - **envelope, - } - - @staticmethod - def _transport_answering(body: dict[str, Any], server) -> FastMCPTransport: - """An in-memory transport whose ``server/discover`` returns ``body`` verbatim.""" - - class _FixedDiscoverTransport(FastMCPTransport): - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - async with super().connect_session(**session_kwargs) as session: - - async def _fixed_discover(version: str) -> dict[str, Any]: - return body - - session.send_discover = _fixed_discover # ty: ignore[invalid-assignment] - yield session - - return _FixedDiscoverTransport(server) - - @pytest.mark.parametrize( - "envelope", - [ - pytest.param({}, id="no-envelope-fields"), - pytest.param( - {"ttlMs": 0, "cacheScope": "private"}, id="github-shape-no-resultType" - ), - pytest.param({"resultType": "complete"}, id="no-cache-fields"), - ], - ) - async def test_non_conformant_discover_falls_back_to_handshake( - self, fastmcp_server, envelope - ): - """A discover result missing required 2026-07-28 fields is not modern evidence. - - Rather than adopting an era the peer cannot serve, auto degrades to the - initialize handshake and the connection stays fully usable. - """ - transport = self._transport_answering( - self._discover_body(**envelope), fastmcp_server - ) - async with Client(transport, mode="auto") as client: - assert client.protocol_version == LATEST_HANDSHAKE_VERSION - assert client.initialize_result is not None - # The connection works, which is the whole point of degrading. - assert await client.list_tools() - result = await client.call_tool("add", {"a": 1, "b": 2}) - assert result.data == 3 - - async def test_conformant_discover_still_adopts_modern(self, fastmcp_server): - """The conformance check must not reject a well-formed modern peer.""" - transport = self._transport_answering( - self._discover_body(resultType="complete", ttlMs=0, cacheScope="private"), - fastmcp_server, - ) - async with Client(transport, mode="auto") as client: - assert client.protocol_version == LATEST_MODERN_VERSION - assert client.initialize_result is None - result = await client.call_tool("add", {"a": 1, "b": 2}) - assert result.data == 3 - class TestPinnedMode: - def test_prior_discover_is_exposed(self, fastmcp_server): - prior = DiscoverResult( - supported_versions=[LATEST_MODERN_VERSION], - capabilities=ServerCapabilities(), - ) - client = Client( - fastmcp_server, - mode=LATEST_MODERN_VERSION, - prior_discover=prior, - ) - - assert client.prior_discover is prior - async def test_pinned_modern_adopts_without_probe(self, fastmcp_server): """Pinning the modern version adopts it directly; a synthesized - DiscoverResult carries no identity, so server_info is absent.""" + DiscoverResult leaves server_info empty.""" async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client: assert client.protocol_version == LATEST_MODERN_VERSION assert client.initialize_result is None - assert client.server_info is None - assert client.instructions is None async def test_pinned_modern_call_tool(self, fastmcp_server): async with Client(fastmcp_server, mode=LATEST_MODERN_VERSION) as client: @@ -271,20 +107,10 @@ class TestPinnedMode: class TestConnectionProperties: - @pytest.mark.parametrize("mode", ["legacy", "auto"]) - async def test_server_metadata_available_across_eras(self, mode): - server = FastMCP("MetadataServer", instructions="Use the metadata tools.") - async with Client(server, mode=mode) as client: - assert client.server_info is not None - assert client.server_info.name == "MetadataServer" - assert client.instructions == "Use the metadata tools." - async def test_properties_none_before_connect(self, fastmcp_server): client = Client(fastmcp_server, mode="auto") assert client.protocol_version is None assert client.server_capabilities is None - assert client.server_info is None - assert client.instructions is None async def test_properties_none_after_disconnect(self, fastmcp_server): client = Client(fastmcp_server, mode="auto") @@ -292,8 +118,6 @@ class TestConnectionProperties: assert client.protocol_version is not None assert client.protocol_version is None assert client.server_capabilities is None - assert client.server_info is None - assert client.instructions is None class TestManualNegotiation: diff --git a/tests/client/client/test_response_cache.py b/tests/client/client/test_response_cache.py index 1d769ccdc..40f583106 100644 --- a/tests/client/client/test_response_cache.py +++ b/tests/client/client/test_response_cache.py @@ -42,11 +42,14 @@ class TestCacheConstruction: def test_cache_none_is_disabled_by_default(self): """Caching is opt-in: the default `cache=None` builds no cache, so a legacy connection is byte-identical to pre-v4 behavior (no handler wrapping).""" + from fastmcp.client.tasks import TaskNotificationHandler + client = Client(FastMCP("x")) assert client._response_cache is None - # No cache means no cache-evicting wrapper: the message handler is the - # bare default (None), not a wrapper. - assert client._session_kwargs.get("message_handler") is None + # The message handler is the bare default, not a cache-evicting wrapper. + assert isinstance( + client._session_kwargs["message_handler"], TaskNotificationHandler + ) def test_cache_true_builds_default(self): client = Client(FastMCP("x"), cache=True) diff --git a/tests/client/client/test_session.py b/tests/client/client/test_session.py index 116bff69c..66b38d773 100644 --- a/tests/client/client/test_session.py +++ b/tests/client/client/test_session.py @@ -1,31 +1,10 @@ """Client session and task error propagation tests.""" import asyncio -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Any -import httpx2 import pytest -from mcp import ClientSession, MCPError -from mcp_types import INTERNAL_ERROR, TextContent -from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.client.transports import ClientTransport, PythonStdioTransport -from fastmcp.client.transports.base import TransportOptions - - -class _FailingTransport(ClientTransport): - def __init__(self, exception: Exception) -> None: - self._exception = exception - - @asynccontextmanager - async def connect_session( - self, **session_kwargs: Any - ) -> AsyncIterator[ClientSession]: - raise self._exception - yield class TestSessionTaskErrorPropagation: @@ -51,7 +30,7 @@ class TestSessionTaskErrorPropagation: async def never_complete(): """A coroutine that will never complete normally.""" - await asyncio.Event().wait() + await asyncio.sleep(1000) async def failing_session(): """Simulates a session task that raises an error.""" @@ -156,146 +135,3 @@ class TestSessionTaskErrorPropagation: # Restore for cleanup client._session_state.session_task = original_task - - -class TestConnectionFailurePropagation: - @pytest.mark.parametrize( - "failure", - [ - MCPError(code=INTERNAL_ERROR, message="upstream failed"), - httpx2.HTTPStatusError( - "upstream unavailable", - request=httpx2.Request("GET", "https://example.com"), - response=httpx2.Response(503), - ), - ], - ids=["mcp-error", "http-status-error"], - ) - async def test_preserves_passthrough_exception(self, failure: Exception): - client = Client(transport=_FailingTransport(failure)) - - with pytest.raises(type(failure)) as exc_info: - async with client: - pass - - assert exc_info.value is failure - assert exc_info.value.__cause__ is not failure - - async def test_wraps_other_failures_with_cause(self): - failure = OSError("connection refused") - client = Client(transport=_FailingTransport(failure)) - - with pytest.raises(RuntimeError, match="Client failed to connect") as exc_info: - async with client: - pass - - assert exc_info.value.__cause__ is failure - - -class TestCustomSessionClass: - """Transports build the session class the client asks for.""" - - async def test_session_class_is_used_when_provided(self): - built: list[str] = [] - - class RecordingClientSession(ClientSession): - def __init__(self, *args, **kwargs): - built.append("yes") - super().__init__(*args, **kwargs) - - server = FastMCP("Server") - - @server.tool - def ping() -> str: - return "pong" - - client = Client(server) - client._transport_options = TransportOptions( - session_class=RecordingClientSession - ) - async with client: - await client.call_tool("ping") - - assert built == ["yes"] - - async def test_default_session_class_is_client_session(self): - server = FastMCP("Server") - - @server.tool - def ping() -> str: - return "pong" - - client = Client(server) - assert TransportOptions().session_class is ClientSession - async with client: - result = await client.call_tool("ping") - - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "pong" - - -class TestKeepAliveSessionsRespectClientOptions: - """A cached stdio session must not be handed to a client wanting different options. - - `StdioTransport` keeps its subprocess and session alive between connections - by default. Serving that cached session to a second client would give it the - first client's behavior — e.g. an ordinary client silently inheriting a - proxy's non-validating session. Rebuilding it is only safe while nobody else - is using it. - """ - - class Reconnected(Exception): - """Raised in place of a real teardown so the test stops at the guard.""" - - @asynccontextmanager - async def cached_connection(self, monkeypatch, options: TransportOptions): - """A transport that believes it already holds a session built for `options`.""" - transport = PythonStdioTransport(script_path=__file__, keep_alive=True) - - async def never_finishes(): - await asyncio.sleep(60) - - task = asyncio.create_task(never_finishes()) - transport._connect_task = task - transport._session_options = options - - async def fake_disconnect(): - raise TestKeepAliveSessionsRespectClientOptions.Reconnected - - monkeypatch.setattr(transport, "disconnect", fake_disconnect) - try: - yield transport - finally: - task.cancel() - transport._connect_task = None - - async def test_matching_options_reuse_the_cached_session(self, monkeypatch): - options = TransportOptions() - async with self.cached_connection(monkeypatch, options) as transport: - assert await transport.connect(transport_options=options) is None - - async def test_differing_options_rebuild_an_idle_session(self, monkeypatch): - class OtherSession(ClientSession): - pass - - async with self.cached_connection(monkeypatch, TransportOptions()) as transport: - with pytest.raises(self.Reconnected): - await transport.connect( - transport_options=TransportOptions(session_class=OtherSession) - ) - - async def test_differing_options_do_not_disturb_a_session_in_use(self, monkeypatch): - """Tearing down a live session would break the client already on it.""" - - class OtherSession(ClientSession): - pass - - async with self.cached_connection(monkeypatch, TransportOptions()) as transport: - transport._active_sessions = 1 - - with pytest.raises(RuntimeError, match="still using it"): - await transport.connect( - transport_options=TransportOptions(session_class=OtherSession) - ) - - assert transport._connect_task is not None diff --git a/tests/client/minimal_stdio_server.py b/tests/client/minimal_stdio_server.py deleted file mode 100644 index abca59f76..000000000 --- a/tests/client/minimal_stdio_server.py +++ /dev/null @@ -1,192 +0,0 @@ -"""A minimal MCP server spoken over stdio, using only the standard library. - -This is a **test fixture**, not a real server. It exists so that subprocess -lifecycle tests (keep-alive, crash recovery, PID identity) can spawn many -short-lived servers without paying for `import fastmcp` in every child -process. Importing fastmcp and constructing a `FastMCP` instance costs -roughly 0.7s per spawn; this script starts in roughly 0.03s. - -It implements only what those tests exercise: the `initialize` handshake, -`tools/list`, and `tools/call` for two trivial tools. Anything that needs -real FastMCP semantics (tool serialization, error handling, structured -output shapes) must use a real FastMCP server instead. - -The response shapes below were captured from the wire of a real FastMCP -stdio server so that `CallToolResult.data` deserializes identically. - -Usage: - - python minimal_stdio_server.py [--exit-after-calls N] - -With `--exit-after-calls N`, the `pid` tool schedules a clean `os._exit(0)` -shortly after its Nth invocation, simulating a server that shuts itself -down mid-session. -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import threading -from typing import Any - -INT_OUTPUT_SCHEMA: dict[str, Any] = { - "properties": {"result": {"type": "integer"}}, - "required": ["result"], - "type": "object", - "x-fastmcp-wrap-result": True, -} - -STR_OUTPUT_SCHEMA: dict[str, Any] = { - "properties": {"result": {"type": "string"}}, - "required": ["result"], - "type": "object", - "x-fastmcp-wrap-result": True, -} - -TOOLS: list[dict[str, Any]] = [ - { - "name": "pid", - "description": "Gets PID of server", - "inputSchema": { - "properties": {}, - "type": "object", - "additionalProperties": False, - }, - "outputSchema": INT_OUTPUT_SCHEMA, - }, - { - "name": "echo", - "description": "Echoes the message back", - "inputSchema": { - "properties": {"message": {"type": "string"}}, - "required": ["message"], - "type": "object", - "additionalProperties": False, - }, - "outputSchema": STR_OUTPUT_SCHEMA, - }, -] - -METHOD_NOT_FOUND = -32601 -INVALID_PARAMS = -32602 - - -def _wrapped_result(value: int | str) -> dict[str, Any]: - """Mirror how FastMCP reports a scalar return value on the wire.""" - return { - "_meta": {"fastmcp": {"wrap_result": True}}, - "content": [{"type": "text", "text": str(value)}], - "isError": False, - "structuredContent": {"result": value}, - } - - -class MinimalServer: - def __init__(self, exit_after_calls: int | None) -> None: - self.exit_after_calls = exit_after_calls - self.pid_call_count = 0 - - def send(self, message: dict[str, Any]) -> None: - sys.stdout.write(json.dumps(message) + "\n") - sys.stdout.flush() - - def reply(self, request_id: Any, result: dict[str, Any]) -> None: - self.send({"jsonrpc": "2.0", "id": request_id, "result": result}) - - def reply_error(self, request_id: Any, code: int, message: str) -> None: - self.send( - { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": code, "message": message}, - } - ) - - def handle_initialize(self, request_id: Any, params: dict[str, Any]) -> None: - # Echo the client's requested version back. The client rejects any - # version it did not ask for, and echoing keeps this fixture working - # across SDK protocol bumps without edits. - protocol_version = params.get("protocolVersion") - self.reply( - request_id, - { - "protocolVersion": protocol_version, - "capabilities": {"tools": {"listChanged": False}}, - "serverInfo": {"name": "MinimalStdioServer", "version": "1.0.0"}, - }, - ) - - def handle_tools_call(self, request_id: Any, params: dict[str, Any]) -> None: - name = params.get("name") - arguments = params.get("arguments") or {} - - if name == "pid": - self.pid_call_count += 1 - pid = os.getpid() - if ( - self.exit_after_calls is not None - and self.pid_call_count >= self.exit_after_calls - ): - # Reply first, then exit shortly after, so the client sees a - # successful call followed by an unannounced clean shutdown. - self.reply(request_id, _wrapped_result(pid)) - threading.Timer(0.1, lambda: os._exit(0)).start() - return - self.reply(request_id, _wrapped_result(pid)) - return - - if name == "echo": - message = arguments.get("message") - if not isinstance(message, str): - self.reply_error(request_id, INVALID_PARAMS, "message must be a string") - return - self.reply(request_id, _wrapped_result(message)) - return - - self.reply_error(request_id, INVALID_PARAMS, f"Unknown tool: {name}") - - def handle(self, message: dict[str, Any]) -> None: - method = message.get("method") - request_id = message.get("id") - params = message.get("params") or {} - - if request_id is None: - # Notification (e.g. notifications/initialized) — nothing to send. - return - - if method == "initialize": - self.handle_initialize(request_id, params) - elif method == "ping": - self.reply(request_id, {}) - elif method == "tools/list": - self.reply(request_id, {"tools": TOOLS}) - elif method == "tools/call": - self.handle_tools_call(request_id, params) - else: - self.reply_error(request_id, METHOD_NOT_FOUND, f"Unknown method: {method}") - - def run(self) -> None: - for line in sys.stdin: - line = line.strip() - if not line: - continue - try: - message = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(message, dict): - self.handle(message) - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--exit-after-calls", type=int, default=None) - parsed = parser.parse_args() - MinimalServer(exit_after_calls=parsed.exit_after_calls).run() - - -if __name__ == "__main__": - main() diff --git a/tests/client/tasks/conftest.py b/tests/client/tasks/conftest.py new file mode 100644 index 000000000..29d0c9a10 --- /dev/null +++ b/tests/client/tasks/conftest.py @@ -0,0 +1 @@ +"""Configuration for client task tests.""" diff --git a/tests/client/tasks/test_client_prompt_tasks.py b/tests/client/tasks/test_client_prompt_tasks.py new file mode 100644 index 000000000..57f55e0a3 --- /dev/null +++ b/tests/client/tasks/test_client_prompt_tasks.py @@ -0,0 +1,108 @@ +""" +Tests for client-side prompt task methods. + +Tests the client's get_prompt_as_task method. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.tasks import PromptTask + + +@pytest.fixture +async def prompt_server(): + """Create a test server with background-enabled prompts.""" + mcp = FastMCP("prompt-client-test") + + @mcp.prompt(task=True) + async def analysis_prompt(topic: str, style: str = "formal") -> str: + """Generate an analysis prompt.""" + return f"Analyze {topic} in a {style} style" + + @mcp.prompt(task=True) + async def creative_prompt(theme: str) -> str: + """Generate a creative writing prompt.""" + return f"Write a story about {theme}" + + return mcp + + +async def test_get_prompt_as_task_returns_prompt_task(prompt_server): + """get_prompt with task=True returns a PromptTask object.""" + async with Client(prompt_server) as client: + task = await client.get_prompt("analysis_prompt", {"topic": "AI"}, task=True) + + assert isinstance(task, PromptTask) + assert isinstance(task.task_id, str) + + +async def test_prompt_task_server_generated_id(prompt_server): + """get_prompt with task=True gets server-generated task ID.""" + async with Client(prompt_server) as client: + task = await client.get_prompt( + "creative_prompt", + {"theme": "future"}, + task=True, + ) + + # Server should generate a UUID task ID + assert task.task_id is not None + assert isinstance(task.task_id, str) + # UUIDs have hyphens + assert "-" in task.task_id + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_prompt_task_result_returns_get_prompt_result(prompt_server): + """PromptTask.result() returns GetPromptResult.""" + async with Client(prompt_server) as client: + task = await client.get_prompt( + "analysis_prompt", {"topic": "Robotics", "style": "casual"}, task=True + ) + + # Verify background execution + assert not task.returned_immediately + + # Get result + result = await task.result() + + # Result should be GetPromptResult + assert hasattr(result, "description") + assert hasattr(result, "messages") + # Check the rendered message content, not the description + assert len(result.messages) > 0 + assert "Analyze Robotics" in result.messages[0].content.text + + +async def test_prompt_task_await_syntax(prompt_server): + """PromptTask can be awaited directly.""" + async with Client(prompt_server) as client: + task = await client.get_prompt("creative_prompt", {"theme": "ocean"}, task=True) + + # Can await task directly + result = await task + assert "Write a story about ocean" in result.messages[0].content.text + + +async def test_prompt_task_status_and_wait(prompt_server): + """PromptTask supports status() and wait() methods.""" + async with Client(prompt_server) as client: + task = await client.get_prompt("analysis_prompt", {"topic": "Space"}, task=True) + + # Check status + status = await task.status() + assert status.status in ["working", "completed"] + + # Wait for completion + await task.wait(timeout=2.0) + + # Get result + result = await task.result() + assert "Analyze Space" in result.messages[0].content.text diff --git a/tests/client/tasks/test_client_resource_tasks.py b/tests/client/tasks/test_client_resource_tasks.py new file mode 100644 index 000000000..0dda0366f --- /dev/null +++ b/tests/client/tasks/test_client_resource_tasks.py @@ -0,0 +1,119 @@ +""" +Tests for client-side resource task methods. + +Tests the client's read_resource_as_task method. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.tasks import ResourceTask + + +@pytest.fixture +async def resource_server(): + """Create a test server with background-enabled resources.""" + mcp = FastMCP("resource-client-test") + + @mcp.resource("file://document.txt", task=True) + async def document() -> str: + """A document resource.""" + return "Document content here" + + @mcp.resource("file://data/{id}.json", task=True) + async def data_file(id: str) -> str: + """A parameterized data resource.""" + return f'{{"id": "{id}", "value": 42}}' + + return mcp + + +async def test_read_resource_as_task_returns_resource_task(resource_server): + """read_resource with task=True returns a ResourceTask object.""" + async with Client(resource_server) as client: + task = await client.read_resource("file://document.txt", task=True) + + assert isinstance(task, ResourceTask) + assert isinstance(task.task_id, str) + + +async def test_resource_task_server_generated_id(resource_server): + """read_resource with task=True gets server-generated task ID.""" + async with Client(resource_server) as client: + task = await client.read_resource("file://document.txt", task=True) + + # Server should generate a UUID task ID + assert task.task_id is not None + assert isinstance(task.task_id, str) + # UUIDs have hyphens + assert "-" in task.task_id + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on ReadResourceRequestParams, so " + "resource reads cannot be submitted as background tasks over the wire and " + "always graceful-degrade to immediate execution (sdk-feedback #3).", + strict=True, +) +async def test_resource_task_result_returns_read_resource_result(resource_server): + """ResourceTask.result() returns list of ReadResourceContents.""" + async with Client(resource_server) as client: + task = await client.read_resource("file://document.txt", task=True) + + # Verify background execution + assert not task.returned_immediately + + # Get result + result = await task.result() + + # Result should be list of ReadResourceContents + assert isinstance(result, list) + assert len(result) > 0 + assert result[0].text == "Document content here" + + +async def test_resource_task_await_syntax(resource_server): + """ResourceTask can be awaited directly.""" + async with Client(resource_server) as client: + task = await client.read_resource("file://document.txt", task=True) + + # Can await task directly + result = await task + assert result[0].text == "Document content here" + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on ReadResourceRequestParams, so " + "resource reads cannot be submitted as background tasks over the wire and " + "always graceful-degrade to immediate execution (sdk-feedback #3).", + strict=True, +) +async def test_resource_template_task(resource_server): + """Resource templates work with task support.""" + async with Client(resource_server) as client: + task = await client.read_resource("file://data/999.json", task=True) + + # Verify background execution + assert not task.returned_immediately + + # Get result + result = await task.result() + assert '"id": "999"' in result[0].text + + +async def test_resource_task_status_and_wait(resource_server): + """ResourceTask supports status() and wait() methods.""" + async with Client(resource_server) as client: + task = await client.read_resource("file://document.txt", task=True) + + # Check status + status = await task.status() + assert status.status in ["working", "completed"] + + # Wait for completion + await task.wait(timeout=2.0) + + # Get result + result = await task.result() + assert "Document content" in result[0].text diff --git a/tests/client/tasks/test_client_task_notifications.py b/tests/client/tasks/test_client_task_notifications.py new file mode 100644 index 000000000..94f7e1db3 --- /dev/null +++ b/tests/client/tasks/test_client_task_notifications.py @@ -0,0 +1,236 @@ +""" +Tests for client-side handling of notifications/tasks/status (SEP-1686 lines 436-444). + +Verifies that Task objects receive notifications, update their cache, wake up wait() calls, +and invoke user callbacks. +""" + +import asyncio +import time +from datetime import datetime, timezone + +import pytest +from mcp_types import GetTaskResult + +from fastmcp import FastMCP +from fastmcp.client import Client + + +@pytest.fixture +async def task_notification_server(): + """Server that sends task status notifications.""" + mcp = FastMCP("task-notification-test") + + @mcp.tool(task=True) + async def quick_task(value: int) -> int: + """Quick background task.""" + await asyncio.sleep(0.05) + return value * 2 + + @mcp.tool(task=True) + async def slow_task(duration: float = 0.2) -> str: + """Slow background task.""" + await asyncio.sleep(duration) + return "done" + + @mcp.tool(task=True) + async def failing_task() -> str: + """Task that fails.""" + raise ValueError("Intentional failure") + + return mcp + + +async def test_task_receives_status_notification(task_notification_server): + """Task object receives and processes status notifications.""" + async with Client(task_notification_server) as client: + task = await client.call_tool("quick_task", {"value": 5}, task=True) + + # Wait for task to complete (notification should arrive) + status = await task.wait(timeout=2.0) + + # Verify task completed + assert status.status == "completed" + + +async def test_status_cache_updated_by_notification(task_notification_server): + """Cached status is updated when notification arrives.""" + async with Client(task_notification_server) as client: + task = await client.call_tool("quick_task", {"value": 10}, task=True) + + # Wait for completion (notification should update cache) + await task.wait(timeout=2.0) + + # Status should be cached (no server call needed) + # Call status() twice - should return same cached object + status1 = await task.status() + status2 = await task.status() + + # Should be the exact same object (from cache) + assert status1 is status2 + assert status1.status == "completed" + + +async def test_callback_invoked_on_notification(task_notification_server): + """User callback is invoked when notification arrives.""" + callback_invocations = [] + + def status_callback(status: GetTaskResult): + """Sync callback.""" + callback_invocations.append(status) + + async with Client(task_notification_server) as client: + task = await client.call_tool("quick_task", {"value": 7}, task=True) + + # Register callback + task.on_status_change(status_callback) + + # Wait for completion + await task.wait(timeout=2.0) + + # Give callbacks a moment to fire + await asyncio.sleep(0.1) + + # Callback should have been invoked at least once + assert len(callback_invocations) > 0 + + # Should have received completed status + completed_statuses = [s for s in callback_invocations if s.status == "completed"] + assert len(completed_statuses) > 0 + + +async def test_async_callback_invoked(task_notification_server): + """Async callback is invoked when notification arrives.""" + callback_invocations = [] + + async def async_status_callback(status: GetTaskResult): + """Async callback.""" + await asyncio.sleep(0.01) # Simulate async work + callback_invocations.append(status) + + async with Client(task_notification_server) as client: + task = await client.call_tool("quick_task", {"value": 3}, task=True) + + # Register async callback + task.on_status_change(async_status_callback) + + # Wait for completion + await task.wait(timeout=2.0) + + # Give async callbacks time to complete + await asyncio.sleep(0.2) + + # Async callback should have been invoked + assert len(callback_invocations) > 0 + + +async def test_multiple_callbacks_all_invoked(task_notification_server): + """Multiple callbacks are all invoked.""" + callback1_calls = [] + callback2_calls = [] + + def callback1(status: GetTaskResult): + callback1_calls.append(status.status) + + def callback2(status: GetTaskResult): + callback2_calls.append(status.status) + + async with Client(task_notification_server) as client: + task = await client.call_tool("quick_task", {"value": 8}, task=True) + + task.on_status_change(callback1) + task.on_status_change(callback2) + + await task.wait(timeout=2.0) + await asyncio.sleep(0.1) + + # Both callbacks should have been invoked + assert len(callback1_calls) > 0 + assert len(callback2_calls) > 0 + + +async def test_callback_error_doesnt_break_notification(task_notification_server): + """Callback errors don't prevent other callbacks from running.""" + callback1_calls = [] + callback2_calls = [] + + def failing_callback(status: GetTaskResult): + callback1_calls.append("called") + raise ValueError("Callback intentionally fails") + + def working_callback(status: GetTaskResult): + callback2_calls.append(status.status) + + async with Client(task_notification_server) as client: + task = await client.call_tool("quick_task", {"value": 12}, task=True) + + task.on_status_change(failing_callback) + task.on_status_change(working_callback) + + await task.wait(timeout=2.0) + await asyncio.sleep(0.1) + + # Failing callback was called (and errored) + assert len(callback1_calls) > 0 + + # Working callback should still have been invoked + assert len(callback2_calls) > 0 + + +async def test_wait_wakes_early_on_notification(task_notification_server): + """wait() wakes up immediately when notification arrives, not after poll interval.""" + async with Client(task_notification_server) as client: + task = await client.call_tool("quick_task", {"value": 15}, task=True) + + # Record timing + start = time.time() + status = await task.wait(timeout=5.0) + elapsed = time.time() - start + + # Should complete much faster than the fallback poll interval (500ms) + # With notifications, should be < 200ms for quick task + # Without notifications, would take 500ms+ due to polling + assert elapsed < 1.0 # Very generous bound + assert status.status == "completed" + + +async def test_notification_with_failed_task(task_notification_server): + """Notifications work for failed tasks too.""" + async with Client(task_notification_server) as client: + task = await client.call_tool("failing_task", {}, task=True) + + with pytest.raises(Exception): + await task + + # Should have cached the failed status from notification + status = await task.status() + assert status.status == "failed" + assert ( + status.status_message is not None + ) # Error details in statusMessage per spec + + +async def test_wait_returns_on_input_required(task_notification_server): + """wait() should return immediately when task enters input_required, not hang.""" + async with Client(task_notification_server) as client: + task = await client.call_tool("quick_task", {"value": 1}, task=True) + + # Directly inject an input_required status into the cache and signal the event. + # SDK v2 types the Task timestamps as ISO 8601 strings. + now = datetime.now(timezone.utc).isoformat() + input_required_status = GetTaskResult( + task_id=task._task_id, + status="input_required", + status_message="Waiting for user input", + created_at=now, + last_updated_at=now, + ttl=None, + ) + task._status_cache = input_required_status + if task._status_event is None: + task._status_event = asyncio.Event() + task._status_event.set() + + # Should return immediately with input_required, not hang for 300s + status = await task.wait(timeout=2.0) + assert status.status == "input_required" diff --git a/tests/client/tasks/test_client_task_protocol.py b/tests/client/tasks/test_client_task_protocol.py new file mode 100644 index 000000000..e8b29afd9 --- /dev/null +++ b/tests/client/tasks/test_client_task_protocol.py @@ -0,0 +1,85 @@ +""" +Tests for client-side task protocol. + +Generic protocol tests that use tools as test fixtures. +""" + +import asyncio + +from fastmcp import FastMCP +from fastmcp.client import Client + + +async def test_end_to_end_task_flow(): + """Complete end-to-end flow: submit, poll, retrieve.""" + start_signal = asyncio.Event() + complete_signal = asyncio.Event() + + mcp = FastMCP("protocol-test") + + @mcp.tool(task=True) + async def controlled_tool(message: str) -> str: + """Tool with controlled execution.""" + start_signal.set() + await complete_signal.wait() + return f"Processed: {message}" + + async with Client(mcp) as client: + # Submit task + task = await client.call_tool( + "controlled_tool", {"message": "integration test"}, task=True + ) + + # Wait for execution to start + await asyncio.wait_for(start_signal.wait(), timeout=2.0) + + # Check status while running + status = await task.status() + assert status.status in ["working"] + + # Signal completion + complete_signal.set() + + # Wait for task to finish and retrieve result + result = await task.result() + assert result.data == "Processed: integration test" + + +async def test_multiple_concurrent_tasks(): + """Multiple tasks can run concurrently.""" + mcp = FastMCP("concurrent-test") + + @mcp.tool(task=True) + async def multiply(a: int, b: int) -> int: + return a * b + + async with Client(mcp) as client: + # Submit multiple tasks + tasks = [] + for i in range(5): + task = await client.call_tool("multiply", {"a": i, "b": 2}, task=True) + tasks.append((task, i * 2)) + + # Wait for all to complete and verify results + for task, expected in tasks: + result = await task.result() + assert result.data == expected + + +async def test_task_id_auto_generation(): + """Task IDs are auto-generated if not provided.""" + mcp = FastMCP("id-test") + + @mcp.tool(task=True) + async def echo(message: str) -> str: + return f"Echo: {message}" + + async with Client(mcp) as client: + # Submit without custom task ID + task_1 = await client.call_tool("echo", {"message": "first"}, task=True) + task_2 = await client.call_tool("echo", {"message": "second"}, task=True) + + # Should generate different IDs + assert task_1.task_id != task_2.task_id + assert len(task_1.task_id) > 0 + assert len(task_2.task_id) > 0 diff --git a/tests/client/tasks/test_client_tool_tasks.py b/tests/client/tasks/test_client_tool_tasks.py new file mode 100644 index 000000000..0bec286cb --- /dev/null +++ b/tests/client/tasks/test_client_tool_tasks.py @@ -0,0 +1,156 @@ +""" +Tests for client-side tool task methods. + +Tests the client's tool-specific task functionality, parallel to +test_client_prompt_tasks.py and test_client_resource_tasks.py. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.tasks import ToolTask +from fastmcp.exceptions import ToolError + + +@pytest.fixture +async def tool_task_server(): + """Create a test server with task-enabled tools.""" + mcp = FastMCP("tool-task-test") + + @mcp.tool(task=True) + async def echo(message: str) -> str: + """Echo back the message.""" + return f"Echo: {message}" + + @mcp.tool(task=True) + async def multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b + + return mcp + + +async def test_call_tool_as_task_returns_tool_task(tool_task_server): + """call_tool with task=True returns a ToolTask object.""" + async with Client(tool_task_server) as client: + task = await client.call_tool("echo", {"message": "hello"}, task=True) + + assert isinstance(task, ToolTask) + assert isinstance(task.task_id, str) + assert len(task.task_id) > 0 + + +async def test_tool_task_server_generated_id(tool_task_server): + """call_tool with task=True gets server-generated task ID.""" + async with Client(tool_task_server) as client: + task = await client.call_tool("echo", {"message": "test"}, task=True) + + # Server should generate a UUID task ID + assert task.task_id is not None + assert isinstance(task.task_id, str) + # UUIDs have hyphens + assert "-" in task.task_id + + +async def test_tool_task_result_returns_call_tool_result(tool_task_server): + """ToolTask.result() returns CallToolResult with tool data.""" + async with Client(tool_task_server) as client: + task = await client.call_tool("multiply", {"a": 6, "b": 7}, task=True) + assert not task.returned_immediately + + result = await task.result() + assert result.data == 42 + + +async def test_tool_task_await_syntax(tool_task_server): + """Tool tasks can be awaited directly to get result.""" + async with Client(tool_task_server) as client: + task = await client.call_tool("multiply", {"a": 7, "b": 6}, task=True) + + # Can await task directly (syntactic sugar for task.result()) + result = await task + assert result.data == 42 + + +async def test_tool_task_status_and_wait(tool_task_server): + """ToolTask.status() returns GetTaskResult.""" + async with Client(tool_task_server) as client: + task = await client.call_tool("echo", {"message": "test"}, task=True) + + status = await task.status() + assert status.task_id == task.task_id + assert status.status in ["working", "completed"] + + # Wait for completion + await task.wait(timeout=2.0) + final_status = await task.status() + assert final_status.status == "completed" + + +async def test_immediate_tool_task_respects_raise_on_error_true(): + """Immediate task fallback should still raise ToolError when requested.""" + mcp = FastMCP("immediate-tool-task-error") + + @mcp.tool + def failing_tool() -> str: + raise ValueError("immediate task failure") + + async with Client(mcp) as client: + task = await client.call_tool("failing_tool", task=True, raise_on_error=True) + + assert task.returned_immediately + with pytest.raises( + ToolError, match="does not support task-augmented execution" + ): + await task.result() + + +async def test_immediate_tool_task_respects_raise_on_error_false(): + """Immediate task fallback should return error results when requested.""" + mcp = FastMCP("immediate-tool-task-no-raise") + + @mcp.tool + def failing_tool() -> str: + raise ValueError("immediate task failure") + + async with Client(mcp) as client: + task = await client.call_tool("failing_tool", task=True, raise_on_error=False) + + assert task.returned_immediately + result = await task.result() + assert result.is_error is True + assert "does not support task-augmented execution" in str(result) + + +async def test_background_tool_task_respects_raise_on_error_true(): + """Background tasks should still raise ToolError by default on errors.""" + mcp = FastMCP("background-tool-task-error") + + @mcp.tool(task=True) + async def failing_tool() -> str: + raise ValueError("background task failure") + + async with Client(mcp) as client: + task = await client.call_tool("failing_tool", task=True, raise_on_error=True) + + assert not task.returned_immediately + with pytest.raises(ToolError, match="background task failure"): + await task.result() + + +async def test_background_tool_task_respects_raise_on_error_false(): + """Background tasks should return error results when raise_on_error is disabled.""" + mcp = FastMCP("background-tool-task-no-raise") + + @mcp.tool(task=True) + async def failing_tool() -> str: + raise ValueError("background task failure") + + async with Client(mcp) as client: + task = await client.call_tool("failing_tool", task=True, raise_on_error=False) + + assert not task.returned_immediately + result = await task.result() + assert result.is_error is True + assert "background task failure" in str(result) diff --git a/tests/client/tasks/test_task_context_validation.py b/tests/client/tasks/test_task_context_validation.py new file mode 100644 index 000000000..fb4765e52 --- /dev/null +++ b/tests/client/tasks/test_task_context_validation.py @@ -0,0 +1,222 @@ +""" +Tests for Task client context validation. + +Verifies that Task methods properly validate client context and that +cached results remain accessible outside context. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client + + +@pytest.fixture +async def task_server(): + """Create a test server with background tasks.""" + mcp = FastMCP("context-test-server") + + @mcp.tool(task=True) + async def background_tool(value: str) -> str: + """Tool that runs in background.""" + return f"Result: {value}" + + @mcp.prompt(task=True) + async def background_prompt(topic: str) -> str: + """Prompt that runs in background.""" + return f"Prompt about {topic}" + + @mcp.resource("file://background.txt", task=True) + async def background_resource() -> str: + """Resource that runs in background.""" + return "Background resource content" + + return mcp + + +async def test_task_status_outside_context_raises(task_server): + """Calling task.status() outside client context raises error.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + assert not task.returned_immediately + # Now outside context + + with pytest.raises(RuntimeError, match="outside client context"): + await task.status() + + +async def test_task_result_outside_context_raises(task_server): + """Calling task.result() outside context raises error.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + assert not task.returned_immediately + # Now outside context + + with pytest.raises(RuntimeError, match="outside client context"): + await task.result() + + +async def test_task_wait_outside_context_raises(task_server): + """Calling task.wait() outside context raises error.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + assert not task.returned_immediately + # Now outside context + + with pytest.raises(RuntimeError, match="outside client context"): + await task.wait() + + +async def test_task_cancel_outside_context_raises(task_server): + """Calling task.cancel() outside context raises error.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + assert not task.returned_immediately + # Now outside context + + with pytest.raises(RuntimeError, match="outside client context"): + await task.cancel() + + +async def test_cached_tool_task_accessible_outside_context(task_server): + """Tool tasks with cached results work outside context.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + assert not task.returned_immediately + + # Get result once to cache it + result1 = await task.result() + assert result1.data == "Result: test" + # Now outside context + + # Should work because result is cached + result2 = await task.result() + assert result2 is result1 # Same object + assert result2.data == "Result: test" + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_cached_prompt_task_accessible_outside_context(task_server): + """Prompt tasks with cached results work outside context.""" + task = None + async with Client(task_server) as client: + task = await client.get_prompt( + "background_prompt", {"topic": "test"}, task=True + ) + assert not task.returned_immediately + + # Get result once to cache it + result1 = await task.result() + assert result1.description == "Prompt that runs in background." + # Now outside context + + # Should work because result is cached + result2 = await task.result() + assert result2 is result1 # Same object + assert result2.description == "Prompt that runs in background." + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_cached_resource_task_accessible_outside_context(task_server): + """Resource tasks with cached results work outside context.""" + task = None + async with Client(task_server) as client: + task = await client.read_resource("file://background.txt", task=True) + assert not task.returned_immediately + + # Get result once to cache it + result1 = await task.result() + assert len(result1) > 0 + # Now outside context + + # Should work because result is cached + result2 = await task.result() + assert result2 is result1 # Same object + + +async def test_uncached_status_outside_context_raises(task_server): + """Even after caching result, status() still requires client context.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + assert not task.returned_immediately + + # Cache the result + await task.result() + # Now outside context + + # result() works (cached) + result = await task.result() + assert result.data == "Result: test" + + # But status() still needs client connection + with pytest.raises(RuntimeError, match="outside client context"): + await task.status() + + +async def test_task_await_syntax_outside_context_raises(task_server): + """Using await task syntax outside context raises error for background tasks.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + assert not task.returned_immediately + # Now outside context + + with pytest.raises(RuntimeError, match="outside client context"): + await task # Same as await task.result() + + +async def test_task_await_syntax_works_for_cached_results(task_server): + """Using await task syntax works outside context when result is cached.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + result1 = await task # Cache it + # Now outside context + + result2 = await task # Should work (cached) + assert result2 is result1 + assert result2.data == "Result: test" + + +async def test_multiple_result_calls_return_same_cached_object(task_server): + """Multiple result() calls return the same cached object.""" + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + + result1 = await task.result() + result2 = await task.result() + result3 = await task.result() + + # Should all be the same object (cached) + assert result1 is result2 + assert result2 is result3 + + +async def test_background_task_properties_accessible_outside_context(task_server): + """Background task properties like task_id accessible outside context.""" + task = None + async with Client(task_server) as client: + task = await client.call_tool("background_tool", {"value": "test"}, task=True) + task_id_inside = task.task_id + assert not task.returned_immediately + # Now outside context + + # Properties should still be accessible (they don't need client connection) + assert task.task_id == task_id_inside + assert task.returned_immediately is False diff --git a/tests/client/tasks/test_task_result_caching.py b/tests/client/tasks/test_task_result_caching.py new file mode 100644 index 000000000..fdb48e129 --- /dev/null +++ b/tests/client/tasks/test_task_result_caching.py @@ -0,0 +1,339 @@ +""" +Tests for Task result caching behavior. + +Verifies that Task.result() and await task cache results properly to avoid +redundant server calls and ensure consistent object identity. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client + + +async def test_tool_task_result_cached_on_first_call(): + """First call caches result, subsequent calls return cached value.""" + call_count = 0 + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def counting_tool() -> int: + nonlocal call_count + call_count += 1 + return call_count + + async with Client(mcp) as client: + task = await client.call_tool("counting_tool", task=True) + + result1 = await task.result() + result2 = await task.result() + result3 = await task.result() + + # All should return 1 (first execution value) + assert result1.data == 1 + assert result2.data == 1 + assert result3.data == 1 + + # Verify they're the same object (cached) + assert result1 is result2 is result3 + + +async def test_prompt_task_result_cached(): + """PromptTask caches results on first call.""" + call_count = 0 + mcp = FastMCP("test") + + @mcp.prompt(task=True) + async def counting_prompt() -> str: + nonlocal call_count + call_count += 1 + return f"Call number: {call_count}" + + async with Client(mcp) as client: + task = await client.get_prompt("counting_prompt", task=True) + + result1 = await task.result() + result2 = await task.result() + result3 = await task.result() + + # All should return same content + assert result1.messages[0].content.text == "Call number: 1" + assert result2.messages[0].content.text == "Call number: 1" + assert result3.messages[0].content.text == "Call number: 1" + + # Verify they're the same object (cached) + assert result1 is result2 is result3 + + +async def test_resource_task_result_cached(): + """ResourceTask caches results on first call.""" + call_count = 0 + mcp = FastMCP("test") + + @mcp.resource("file://counter.txt", task=True) + async def counting_resource() -> str: + nonlocal call_count + call_count += 1 + return f"Count: {call_count}" + + async with Client(mcp) as client: + task = await client.read_resource("file://counter.txt", task=True) + + result1 = await task.result() + result2 = await task.result() + result3 = await task.result() + + # All should return same content + assert result1[0].text == "Count: 1" + assert result2[0].text == "Count: 1" + assert result3[0].text == "Count: 1" + + # Verify they're the same object (cached) + assert result1 is result2 is result3 + + +async def test_multiple_await_returns_same_object(): + """Multiple await task calls return identical object.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def sample_tool() -> str: + return "result" + + async with Client(mcp) as client: + task = await client.call_tool("sample_tool", task=True) + + result1 = await task + result2 = await task + result3 = await task + + # Should be exact same object in memory + assert result1 is result2 is result3 + assert id(result1) == id(result2) == id(result3) + + +async def test_result_and_await_share_cache(): + """task.result() and await task share the same cache.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def sample_tool() -> str: + return "cached" + + async with Client(mcp) as client: + task = await client.call_tool("sample_tool", task=True) + + # Call result() first + result_via_method = await task.result() + + # Then await directly + result_via_await = await task + + # Should be the same cached object + assert result_via_method is result_via_await + assert id(result_via_method) == id(result_via_await) + + +async def test_forbidden_mode_tool_caches_error_result(): + """Tools with task=False (mode=forbidden) cache error results.""" + mcp = FastMCP("test") + + @mcp.tool(task=False) + async def non_task_tool() -> int: + return 1 + + async with Client(mcp) as client: + # Request as task, but mode="forbidden" will reject with error + task = await client.call_tool("non_task_tool", task=True, raise_on_error=False) + + # Should be immediate (error returned immediately) + assert task.returned_immediately + + result1 = await task.result() + result2 = await task.result() + result3 = await task.result() + + # All should return cached error + assert result1.is_error + assert "does not support task-augmented execution" in str(result1) + + # Verify they're the same object (cached) + assert result1 is result2 is result3 + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_forbidden_mode_prompt_raises_error(): + """Prompts with task=False (mode=forbidden) raise error.""" + import pytest + from mcp.shared.exceptions import MCPError + + mcp = FastMCP("test") + + @mcp.prompt(task=False) + async def non_task_prompt() -> str: + return "Immediate" + + async with Client(mcp) as client: + # Prompts with mode="forbidden" raise MCPError when called with task=True + with pytest.raises(MCPError): + await client.get_prompt("non_task_prompt", task=True) + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_forbidden_mode_resource_raises_error(): + """Resources with task=False (mode=forbidden) raise error.""" + import pytest + from mcp.shared.exceptions import MCPError + + mcp = FastMCP("test") + + @mcp.resource("file://immediate.txt", task=False) + async def non_task_resource() -> str: + return "Immediate" + + async with Client(mcp) as client: + # Resources with mode="forbidden" raise MCPError when called with task=True + with pytest.raises(MCPError): + await client.read_resource("file://immediate.txt", task=True) + + +async def test_immediate_task_caches_result(): + """Immediate tasks (optional mode called without background) cache results.""" + call_count = 0 + mcp = FastMCP("test", tasks=True) + + # Tool with task=True (optional mode) - but without docket will execute immediately + @mcp.tool(task=True) + async def task_tool() -> int: + nonlocal call_count + call_count += 1 + return call_count + + async with Client(mcp) as client: + # Call with task=True + task = await client.call_tool("task_tool", task=True) + + # Get result multiple times + result1 = await task.result() + result2 = await task.result() + result3 = await task.result() + + # All should return cached value + assert result1.data == 1 + assert result2.data == 1 + assert result3.data == 1 + + # Verify they're the same object (cached) + assert result1 is result2 is result3 + + +async def test_cache_persists_across_mixed_access_patterns(): + """Cache works correctly when mixing result() and await.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def mixed_tool() -> str: + return "mixed" + + async with Client(mcp) as client: + task = await client.call_tool("mixed_tool", task=True) + + # Access in various orders + result1 = await task + result2 = await task.result() + result3 = await task + result4 = await task.result() + + # All should be the same cached object + assert result1 is result2 is result3 is result4 + + +async def test_different_tasks_have_separate_caches(): + """Different task instances maintain separate caches.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def separate_tool(value: str) -> str: + return f"Result: {value}" + + async with Client(mcp) as client: + task1 = await client.call_tool("separate_tool", {"value": "A"}, task=True) + task2 = await client.call_tool("separate_tool", {"value": "B"}, task=True) + + result1 = await task1.result() + result2 = await task2.result() + + # Different results + assert result1.data == "Result: A" + assert result2.data == "Result: B" + + # Not the same object + assert result1 is not result2 + + # But each task's cache works independently + result1_again = await task1.result() + result2_again = await task2.result() + + assert result1 is result1_again + assert result2 is result2_again + + +async def test_cache_survives_status_checks(): + """Calling status() doesn't affect result caching.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def status_check_tool() -> str: + return "status" + + async with Client(mcp) as client: + task = await client.call_tool("status_check_tool", task=True) + + # Check status multiple times + await task.status() + await task.status() + + result1 = await task.result() + + # Check status again + await task.status() + + result2 = await task.result() + + # Cache should still work + assert result1 is result2 + + +async def test_cache_survives_wait_calls(): + """Calling wait() doesn't affect result caching.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def wait_test_tool() -> str: + return "waited" + + async with Client(mcp) as client: + task = await client.call_tool("wait_test_tool", task=True) + + # Wait for completion + await task.wait() + + result1 = await task.result() + + # Wait again (no-op since completed) + await task.wait() + + result2 = await task.result() + + # Cache should still work + assert result1 is result2 diff --git a/tests/client/telemetry/test_client_task_tracing.py b/tests/client/telemetry/test_client_task_tracing.py new file mode 100644 index 000000000..4d0b71718 --- /dev/null +++ b/tests/client/telemetry/test_client_task_tracing.py @@ -0,0 +1,93 @@ +"""Tests for client OpenTelemetry tracing on task operations.""" + +import asyncio + +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.trace import SpanKind + +from fastmcp import Client, FastMCP + + +def assert_propagating_client_span( + trace_exporter: InMemorySpanExporter, + method: str, + component_key: str, +) -> None: + all_spans = trace_exporter.get_finished_spans() + spans = [span for span in all_spans if span.name == method] + client_span = next( + span + for span in spans + if span.attributes is not None and "fastmcp.server.name" not in span.attributes + ) + server_span = next( + span + for span in spans + if span.attributes is not None and "fastmcp.server.name" in span.attributes + ) + + assert client_span.kind == SpanKind.CLIENT + assert client_span.attributes is not None + assert client_span.attributes["mcp.method.name"] == method + assert client_span.attributes["fastmcp.component.key"] == component_key + assert server_span.parent is not None + assert server_span.context.trace_id == client_span.context.trace_id + + spans_by_id = {span.context.span_id: span for span in all_spans} + current = server_span + while current.parent is not None: + parent = spans_by_id.get(current.parent.span_id) + assert parent is not None + if parent.context.span_id == client_span.context.span_id: + break + current = parent + else: + raise AssertionError("Server span should descend from the client span") + + +async def test_list_tasks_creates_propagating_client_span( + trace_exporter: InMemorySpanExporter, +): + server = FastMCP("test-server") + + async with Client(server) as client: + await client.list_tasks() + + assert_propagating_client_span(trace_exporter, "tasks/list", "") + + +async def test_task_id_operations_create_propagating_client_spans( + trace_exporter: InMemorySpanExporter, +): + started = asyncio.Event() + server = FastMCP("test-server") + + @server.tool(task=True) + async def quick_tool() -> str: + return "done" + + @server.tool(task=True) + async def slow_tool() -> str: + started.set() + await asyncio.sleep(10) + return "done" + + async with Client(server) as client: + completed_task = await client.call_tool("quick_tool", task=True) + await completed_task.wait(timeout=2) + trace_exporter.clear() + + await client.get_task_status(completed_task.task_id) + await client.get_task_result(completed_task.task_id) + + running_task = await client.call_tool("slow_tool", task=True) + await asyncio.wait_for(started.wait(), timeout=2) + await client.cancel_task(running_task.task_id) + + assert_propagating_client_span(trace_exporter, "tasks/get", completed_task.task_id) + assert_propagating_client_span( + trace_exporter, "tasks/result", completed_task.task_id + ) + assert_propagating_client_span(trace_exporter, "tasks/cancel", running_task.task_id) diff --git a/tests/client/telemetry/test_client_tracing.py b/tests/client/telemetry/test_client_tracing.py index 0ca2e8759..1180a40dc 100644 --- a/tests/client/telemetry/test_client_tracing.py +++ b/tests/client/telemetry/test_client_tracing.py @@ -589,7 +589,7 @@ class TestSessionIdOnSpans: from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport(http_server_url) - client = Client(transport=transport, mode="legacy") + client = Client(transport=transport) async with client: await client.call_tool("echo", {"message": "test"}) @@ -657,7 +657,7 @@ class TestSessionIdOnSpans: from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport(http_server_url) - client = Client(transport=transport, mode="legacy") + client = Client(transport=transport) async with client: await client.call_tool("echo", {"message": "test"}) diff --git a/tests/client/test_client_extensions.py b/tests/client/test_client_extensions.py deleted file mode 100644 index 1a054ae83..000000000 --- a/tests/client/test_client_extensions.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Tests for surfacing SEP-2133 client extensions on ``fastmcp.Client``. - -Covers that ``extensions=`` / ``result_claims=`` are folded into the underlying -``ClientSession`` kwargs on construction, that a claimed ``tools/call`` result is -resolved end-to-end through the owning extension's resolver, and that FastMCP's -internal tasks extension (from ``fastmcp-tasks``, imported below) is folded in -automatically and *composes* with a user's own extensions rather than being -clobbered by them. - -Importing ``fastmcp_tasks`` registers the internal client extension factory -process-wide, so every ``Client`` built here carries the tasks capability ad and -its ``resultType: "task"`` claim. These tests assert that composition explicitly. -""" - -from typing import Any, Literal - -import pytest -from fastmcp_tasks.client_models import ClientCreateTaskResult -from mcp.client.extension import ( - ClaimContext, - ClientExtension, - NotificationBinding, - ResultClaim, - UnexpectedClaimedResult, -) -from mcp.server.context import CallNext, HandlerResult, ServerRequestContext -from mcp.server.extension import Extension -from mcp.server.mcpserver import MCPServer as SDKServer -from mcp_types import CallToolRequestParams, CallToolResult, Result, TextContent -from mcp_types.version import LATEST_MODERN_VERSION -from pydantic import BaseModel - -# Importing the package registers the internal tasks client extension factory, so -# every Client below folds the tasks extension in. Kept as an explicit import so -# the composition assertions are deterministic regardless of test import order. -import fastmcp_tasks # noqa: F401 -from fastmcp import FastMCP -from fastmcp.client import Client -from fastmcp.utilities.tasks import TASKS_EXTENSION_ID - -CUSTOM_METHOD = "notifications/x-test/ping" -EXTENSION_ID = "test.example.com/demo" -CLAIMED_TYPE = "x-test/claimed" - - -class PingParams(BaseModel): - value: int = 0 - - -class ClaimedResult(Result): - result_type: Literal["x-test/claimed"] - payload: str = "" - - -async def _resolve_claimed(result: ClaimedResult, ctx: ClaimContext) -> CallToolResult: - """Finish a claimed result into an ordinary CallToolResult. - - Echoes the claimed payload so a test can prove the resolver ran on the - server-emitted value rather than a placeholder. - """ - return CallToolResult( - content=[TextContent(type="text", text=f"resolved:{result.payload}")] - ) - - -def _make_claim() -> ResultClaim[ClaimedResult]: - return ResultClaim( - result_type=CLAIMED_TYPE, - model=ClaimedResult, - resolve=_resolve_claimed, - ) - - -class _DemoExtension(ClientExtension): - """Extension contributing a settings ad, a result claim, and a binding.""" - - identifier = EXTENSION_ID - - def __init__(self, received: list[PingParams] | None = None) -> None: - self._received = received if received is not None else [] - - def settings(self) -> dict[str, Any]: - return {"enabled": True} - - def claims(self): - return (_make_claim(),) - - def notifications(self): - async def _handler(params: PingParams) -> None: - self._received.append(params) - - return ( - NotificationBinding( - method=CUSTOM_METHOD, - params_type=PingParams, - handler=_handler, - ), - ) - - -class _ServerClaimExtension(Extension): - """Server-side extension that answers a specific tool with a claimed shape.""" - - identifier = EXTENSION_ID - - async def intercept_tool_call( - self, - params: CallToolRequestParams, - ctx: ServerRequestContext[Any, Any], - call_next: CallNext, - ) -> HandlerResult: - if params.name == "claimed_tool": - return ClaimedResult(result_type=CLAIMED_TYPE, payload="from-server") - return await call_next(ctx) - - -def _claiming_server() -> SDKServer: - """An SDK MCPServer whose `claimed_tool` returns a claimed extension result.""" - server = SDKServer("claim-server", extensions=[_ServerClaimExtension()]) - - # No return annotation → no output schema, so the resolved CallToolResult - # (plain text, no structured content) passes revalidation. - @server.tool() - def claimed_tool(): - return None - - return server - - -def test_extension_folds_into_session_kwargs(): - """A ClientExtension's ad and claim reach the session kwargs, alongside tasks.""" - client = Client(FastMCP("srv"), extensions=[_DemoExtension()]) - - # The tasks extension is auto-folded in beside the user's own. - assert client._session_kwargs.get("extensions") == { - TASKS_EXTENSION_ID: {}, - EXTENSION_ID: {"enabled": True}, - } - result_claims = client._session_kwargs.get("result_claims") - assert result_claims is not None - assert [c.result_type for c in result_claims[EXTENSION_ID]] == [CLAIMED_TYPE] - assert [c.result_type for c in result_claims[TASKS_EXTENSION_ID]] == ["task"] - - -def test_extension_populates_claim_by_model_index(): - """The claim is indexed by its model so the resolution path can find it.""" - client = Client(FastMCP("srv"), extensions=[_DemoExtension()]) - - assert client._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE - # The auto-folded tasks claim is indexed too. - assert client._claim_by_model[ClientCreateTaskResult].result_type == "task" - - -def test_internal_tasks_extension_present_without_user_extensions(): - """Even with no user extensions, the tasks claim is auto-registered.""" - client = Client(FastMCP("srv")) - - assert client._session_kwargs.get("extensions") == {TASKS_EXTENSION_ID: {}} - assert client._claim_by_model[ClientCreateTaskResult].result_type == "task" - - -def test_user_extension_composes_with_internal_tasks_extension(): - """A user extension is folded in beside the internal tasks extension.""" - client = Client(FastMCP("srv"), extensions=[_DemoExtension()]) - - ad = client._session_kwargs.get("extensions") or {} - assert TASKS_EXTENSION_ID in ad - assert EXTENSION_ID in ad - # Both claims are resolvable. - assert set(client._claim_by_model) == {ClaimedResult, ClientCreateTaskResult} - - -def test_user_extension_may_override_internal_tasks_extension(): - """A user extension declaring the tasks identifier wins; the internal one drops. - - Composition prefers the user's extension: rather than colliding on the shared - identifier (which the fold rejects), the internal tasks extension is dropped so - a power user can supply their own task-handling extension. - """ - - class CustomTasks(ClientExtension): - identifier = TASKS_EXTENSION_ID - - def settings(self) -> dict[str, Any]: - return {"custom": True} - - client = Client(FastMCP("srv"), extensions=[CustomTasks()]) - - assert client._session_kwargs.get("extensions") == { - TASKS_EXTENSION_ID: {"custom": True} - } - # The user extension declares no claim, so no task claim is registered. - assert client._claim_by_model == {} - - -def test_new_preserves_extension_composition(): - """new() rebuilds the clone with both the tasks extension and user extensions.""" - client = Client(FastMCP("srv"), extensions=[_DemoExtension()]) - clone = client.new() - - ad = clone._session_kwargs.get("extensions") or {} - assert TASKS_EXTENSION_ID in ad - assert EXTENSION_ID in ad - assert clone._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE - assert clone._claim_by_model[ClientCreateTaskResult].result_type == "task" - - -def test_result_claims_merge_with_extension_claims(): - """Explicit result_claims merge with an advertised extension's own claims.""" - - class ExtraClaimed(Result): - result_type: Literal["x-test/extra"] - - async def _resolve_extra(result: ExtraClaimed, ctx: ClaimContext) -> CallToolResult: - return CallToolResult(content=[]) - - extra_claim = ResultClaim( - result_type="x-test/extra", - model=ExtraClaimed, - resolve=_resolve_extra, - ) - - client = Client( - FastMCP("srv"), - extensions=[_DemoExtension()], - result_claims={EXTENSION_ID: [extra_claim]}, - ) - - result_claims = client._session_kwargs.get("result_claims") - assert result_claims is not None - tags = {c.result_type for c in result_claims[EXTENSION_ID]} - assert tags == {CLAIMED_TYPE, "x-test/extra"} - # The extension claim, the explicit extra claim, and the tasks claim resolve. - assert set(client._claim_by_model) == { - ClaimedResult, - ExtraClaimed, - ClientCreateTaskResult, - } - - -class TestClaimedResultResolution: - """End-to-end resolution of a server-emitted claimed `tools/call` result.""" - - @pytest.mark.parametrize("mode", ["auto", LATEST_MODERN_VERSION]) - async def test_call_tool_mcp_resolves_claimed_result(self, mode): - """`call_tool_mcp` resolves a claimed result through the extension resolver. - - The server emits a claimed shape; the client's registered extension - parses it and its resolver finishes it into an ordinary CallToolResult. - Both the negotiated (`auto`) and pinned modern eras admit the claim. - """ - client = Client(_claiming_server(), extensions=[_DemoExtension()], mode=mode) - async with client: - assert client.protocol_version == LATEST_MODERN_VERSION - result = await client.call_tool_mcp("claimed_tool", {}) - - block = result.content[0] - assert isinstance(block, TextContent) - assert block.text == "resolved:from-server" - - async def test_call_tool_resolves_claimed_result(self): - """The high-level `call_tool` also returns the resolver's CallToolResult.""" - client = Client( - _claiming_server(), - extensions=[_DemoExtension()], - mode=LATEST_MODERN_VERSION, - ) - async with client: - parsed = await client.call_tool("claimed_tool", {}) - - block = parsed.content[0] - assert isinstance(block, TextContent) - assert block.text == "resolved:from-server" - - async def test_unwired_session_call_raises_unexpected_claimed(self): - """Regression guard for the half-wired bug: the raw session path raises. - - With the claim registered, calling `session.call_tool` directly (FastMCP's - old tool path, which omitted `allow_claimed=True`) surfaces the claimed - result as `UnexpectedClaimedResult` — the exact failure the wired - `call_tool_mcp` path now avoids by resolving instead. - """ - client = Client( - _claiming_server(), - extensions=[_DemoExtension()], - mode=LATEST_MODERN_VERSION, - ) - async with client: - with pytest.raises(UnexpectedClaimedResult): - await client.session.call_tool("claimed_tool", {}) - - # The wired path resolves the very same claimed result. - resolved = await client.call_tool_mcp("claimed_tool", {}) - block = resolved.content[0] - assert isinstance(block, TextContent) - assert block.text == "resolved:from-server" diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index 87c89e3a1..2686edb79 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -3,6 +3,7 @@ from enum import Enum from typing import Any, Literal, cast import pytest +from mcp_types import ElicitRequestFormParams, ElicitRequestParams from pydantic import BaseModel from typing_extensions import TypedDict @@ -50,7 +51,7 @@ def fastmcp_server(): async def test_elicitation_with_no_handler(fastmcp_server): """Test that elicitation works without a handler.""" - async with Client(fastmcp_server, mode="legacy") as client: + async with Client(fastmcp_server) as client: with pytest.raises(ToolError, match="Elicitation not supported"): await client.call_tool("ask_for_name") @@ -63,7 +64,7 @@ async def test_elicitation_accept_content(fastmcp_server): return ElicitResult(action="accept", content=response_type(name="Alice")) async with Client( - fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler + fastmcp_server, elicitation_handler=elicitation_handler ) as client: result = await client.call_tool("ask_for_name") assert result.data == "Hello, Alice!" @@ -76,7 +77,7 @@ async def test_elicitation_decline(fastmcp_server): return ElicitResult(action="decline") async with Client( - fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler + fastmcp_server, elicitation_handler=elicitation_handler ) as client: result = await client.call_tool("ask_for_name") assert result.data == "No name provided." @@ -102,9 +103,7 @@ async def test_elicitation_handler_parameters(): captured_params["ctx"] = ctx return ElicitResult(action="accept", content={"value": 42}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: await client.call_tool("test_tool", {}) assert captured_params["message"] == "Test message" @@ -137,9 +136,7 @@ async def test_elicitation_response_title_and_description_on_scalar(): captured_schema.update(params.requested_schema) return ElicitResult(action="accept", content={"value": True}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: await client.call_tool("confirm_purchase", {}) assert captured_schema["properties"]["value"]["title"] == "Confirm purchase" @@ -168,9 +165,7 @@ async def test_elicitation_response_title_on_dict_shorthand(): captured_schema.update(params.requested_schema) return ElicitResult(action="accept", content={"value": "low"}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: await client.call_tool("pick_priority", {}) assert captured_schema["properties"]["value"]["title"] == "Priority level" @@ -194,9 +189,7 @@ async def test_elicitation_response_title_on_list_shorthand(): captured_schema.update(params.requested_schema) return ElicitResult(action="accept", content={"value": "red"}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: await client.call_tool("pick_color", {}) assert captured_schema["properties"]["value"]["title"] == "Favorite color" @@ -221,8 +214,27 @@ async def test_elicitation_response_title_rejected_for_basemodel(): async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"name": "x"}) - # Not pinned: response_title is validated locally before any request is - # dispatched, so this raises identically on every era. + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + with pytest.raises(ToolError, match="response_title"): + await client.call_tool("ask", {}) + + +async def test_elicitation_response_title_rejected_for_none(): + """response_title raises TypeError when response_type is None.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def ask(context: Context) -> str: + await context.elicit( + message="Confirm?", + response_type=None, + response_title="Not allowed", + ) + return "done" + + async def elicitation_handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={}) + async with Client(mcp, elicitation_handler=elicitation_handler) as client: with pytest.raises(ToolError, match="response_title"): await client.call_tool("ask", {}) @@ -249,9 +261,7 @@ async def test_elicitation_cancel_action(): async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="cancel") - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("ask_for_optional_info", {}) assert result.data == "Request was canceled" @@ -271,12 +281,77 @@ class TestScalarResponseTypes: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content="Alice") - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "Alice" + async def test_elicitation_no_response(self): + """Test elicitation with no response type.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(context: Context) -> dict[str, Any]: + result = await context.elicit(message="", response_type=None) + assert isinstance(result, AcceptedElicitation) + assert isinstance(result.data, dict) + return cast(dict[str, Any], result.data) + + async def elicitation_handler( + message, response_type, params: ElicitRequestParams, ctx + ): + assert isinstance(params, ElicitRequestFormParams) + assert params.requested_schema == {"type": "object", "properties": {}} + assert response_type is None + return ElicitResult(action="accept") + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + result = await client.call_tool("my_tool", {}) + assert result.data is None + + async def test_elicitation_empty_response(self): + """Test elicitation with empty response type.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(context: Context) -> dict[str, Any]: + result = await context.elicit(message="", response_type=None) + assert result.action == "accept" + assert isinstance(result, AcceptedElicitation) + accepted = result + assert isinstance(accepted.data, dict) + return accepted.data + + async def elicitation_handler( + message, response_type, params: ElicitRequestParams, ctx + ): + return ElicitResult(action="accept", content={}) + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + result = await client.call_tool("my_tool", {}) + assert result.data is None + + async def test_elicitation_response_when_no_response_requested(self): + """Test elicitation with no response type.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(context: Context) -> dict[str, Any]: + result = await context.elicit(message="", response_type=None) + assert result.action == "accept" + assert isinstance(result, AcceptedElicitation) + accepted = result + assert isinstance(accepted.data, dict) + return accepted.data + + async def elicitation_handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"value": "hello"}) + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + with pytest.raises( + ToolError, match="Elicitation expected an empty response" + ): + await client.call_tool("my_tool", {}) + async def test_elicitation_str_response(self): """Test elicitation with string schema.""" mcp = FastMCP("TestServer") @@ -291,9 +366,7 @@ class TestScalarResponseTypes: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "hello"}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "hello" @@ -311,9 +384,7 @@ class TestScalarResponseTypes: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": 42}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == 42 @@ -331,9 +402,7 @@ class TestScalarResponseTypes: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": 3.14}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == 3.14 @@ -351,9 +420,7 @@ class TestScalarResponseTypes: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": True}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data is True @@ -373,9 +440,7 @@ class TestScalarResponseTypes: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "x"}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "x" @@ -397,9 +462,7 @@ class TestScalarResponseTypes: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "x"}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "x" @@ -417,9 +480,7 @@ class TestScalarResponseTypes: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "x"}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "x" @@ -443,13 +504,7 @@ async def test_elicitation_handler_error(): async def elicitation_handler(message, response_type, params, ctx): raise ValueError("Handler failed!") - # Pinned: the tool's broad `except Exception` means this would pass under - # auto for the wrong reason (elicit() itself raising "unavailable on - # 2026-07-28" rather than the handler's ValueError ever running). Legacy - # pins the test to what it actually claims to exercise. - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("failing_elicit", {}) assert "Error:" in result.data @@ -492,9 +547,7 @@ async def test_elicitation_multiple_calls(): else: raise ValueError("Unexpected call") - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("multi_step_form", {}) assert result.data == "Hello Bob, you are 25 years old" assert call_count == 2 @@ -566,9 +619,7 @@ async def test_structured_response_type( return ElicitResult(action="accept", content=UserInfo(name="Alice", age=30)) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("get_user_info", {}) assert result.data == "User: Alice, age: 30" @@ -615,9 +666,7 @@ async def test_all_primitive_field_types(): ), ) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("get_data", {}) # Now all literal/enum fields should be preserved as strings @@ -636,28 +685,6 @@ async def test_all_primitive_field_types(): } -class TestResponseTypeRequired: - """`response_type` is required — the empty-schema form was removed in 4.0.""" - - async def test_explicit_none_raises_type_error(self): - mcp = FastMCP("TestServer") - - @mcp.tool - async def my_tool(context: Context) -> str: - await context.elicit(message="Approve?", response_type=None) # ty: ignore[no-matching-overload] - return "unreachable" - - async with Client(mcp, mode="legacy") as client: - with pytest.raises(ToolError, match="requires a response_type"): - await client.call_tool("my_tool", {}) - - async def test_omitting_response_type_raises_type_error(self): - ctx = Context(fastmcp=FastMCP("TestServer")) - - with pytest.raises(TypeError, match="response_type"): - await ctx.elicit("Approve?") # ty: ignore[no-matching-overload] - - class TestValidation: async def test_schema_validation_rejects_non_object(self): """Test that non-object schemas are rejected.""" @@ -719,9 +746,7 @@ class TestPatternMatching: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "Alice"}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("pattern_match_tool", {}) assert result.data == "Hello Alice!" @@ -746,9 +771,7 @@ class TestPatternMatching: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="decline") - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("pattern_match_tool", {}) assert result.data == "You declined" @@ -773,8 +796,6 @@ class TestPatternMatching: async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="cancel") - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("pattern_match_tool", {}) assert result.data == "Cancelled" diff --git a/tests/client/test_elicitation_enums.py b/tests/client/test_elicitation_enums.py index 60c9f6ac4..f1beb62d5 100644 --- a/tests/client/test_elicitation_enums.py +++ b/tests/client/test_elicitation_enums.py @@ -54,7 +54,7 @@ async def test_elicitation_implicit_acceptance(fastmcp_server): return response_type(name="Bob") async with Client( - fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler + fastmcp_server, elicitation_handler=elicitation_handler ) as client: result = await client.call_tool("ask_for_name") assert result.data == "Hello, Bob!" @@ -69,7 +69,7 @@ async def test_elicitation_implicit_acceptance_must_be_dict(fastmcp_server): return "Bob" async with Client( - fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler + fastmcp_server, elicitation_handler=elicitation_handler ) as client: with pytest.raises( ToolError, @@ -182,9 +182,7 @@ async def test_dict_based_titled_single_select(): return ElicitResult(action="accept", content={"value": "low"}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "low" @@ -217,9 +215,7 @@ async def test_list_list_multi_select_untitled(): return ElicitResult(action="accept", content={"value": ["bug", "feature"]}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "bug,feature" @@ -260,9 +256,7 @@ async def test_list_dict_multi_select_titled(): return ElicitResult(action="accept", content={"value": ["low", "high"]}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "low,high" @@ -326,9 +320,7 @@ async def test_list_enum_multi_select_direct(): return ElicitResult(action="accept", content={"value": ["low", "high"]}) - async with Client( - mcp, mode="legacy", elicitation_handler=elicitation_handler - ) as client: + async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "low,high" diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py index 6094ac56a..43b6e1d0f 100644 --- a/tests/client/test_logs.py +++ b/tests/client/test_logs.py @@ -95,11 +95,7 @@ class TestSetLoggingLevel: async def test_set_logging_level(self, fastmcp_server: FastMCP): """Client can set the minimum log level and lower-level messages are suppressed.""" log_handler = LogHandler() - # client.set_logging_level is a legacy-only RPC (deprecated per SEP-2577); - # it does not exist on the modern protocol. - async with Client( - fastmcp_server, mode="legacy", log_handler=log_handler.handle_log - ) as client: + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: await client.set_logging_level("warning") await client.call_tool( "echo_log", {"message": "debug msg", "level": "debug"} @@ -119,9 +115,7 @@ class TestSetLoggingLevel: async def test_set_logging_level_debug_allows_all(self, fastmcp_server: FastMCP): """Setting level to debug allows all messages through.""" log_handler = LogHandler() - async with Client( - fastmcp_server, mode="legacy", log_handler=log_handler.handle_log - ) as client: + async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: await client.set_logging_level("debug") await client.call_tool( "echo_log", {"message": "debug msg", "level": "debug"} @@ -175,9 +169,7 @@ class TestSetLoggingLevel: await context.log(message=message, level=level) log_handler = LogHandler() - async with Client( - mcp, mode="legacy", log_handler=log_handler.handle_log - ) as client: + async with Client(mcp, log_handler=log_handler.handle_log) as client: await client.set_logging_level("warning") await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) await client.call_tool( diff --git a/tests/client/test_oauth_callback_race.py b/tests/client/test_oauth_callback_race.py index 417173ea9..43efddee8 100644 --- a/tests/client/test_oauth_callback_race.py +++ b/tests/client/test_oauth_callback_race.py @@ -8,18 +8,6 @@ from fastmcp.client.oauth_callback import ( from fastmcp.utilities.http import find_available_port -async def _wait_until_listening(server) -> None: - """Poll until the callback server's socket is accepting connections. - - uvicorn sets `Server.started = True` right after it binds and starts - listening on the socket, before `serve()` moves on to request handling, - so this is a deterministic readiness signal in place of a fixed sleep. - """ - with anyio.fail_after(5): - while not server.started: - await anyio.sleep(0.001) - - async def test_oauth_callback_result_ignores_subsequent_callbacks(): """Only the first callback should be captured in shared OAuth callback state.""" port = find_available_port() @@ -34,7 +22,7 @@ async def test_oauth_callback_result_ignores_subsequent_callbacks(): async with anyio.create_task_group() as tg: tg.start_soon(server.serve) - await _wait_until_listening(server) + await anyio.sleep(0.05) async with httpx2.AsyncClient() as client: first = await client.get( @@ -60,86 +48,3 @@ def test_oauth_callback_server_uses_configured_host(): server = create_oauth_callback_server(port=find_available_port(), host="localhost") assert server.config.host == "localhost" - - -async def test_oauth_callback_result_captures_iss(): - """RFC 9207: the `iss` query parameter must survive from the raw callback - request through to `OAuthCallbackResult`, the same as `code` and `state`. - - OAuthProxy advertises `authorization_response_iss_parameter_supported` and - includes `iss` on every authorization redirect. If the callback server's - query-parsing chain (CallbackResponse.from_dict -> store_result_once -> - OAuthCallbackResult) drops it, the MCP SDK's `validate_authorization_response_iss` - rejects an otherwise-successful callback. - """ - port = find_available_port() - result = OAuthCallbackResult() - result_ready = anyio.Event() - server = create_oauth_callback_server( - port=port, - result_container=result, - result_ready=result_ready, - ) - - async with anyio.create_task_group() as tg: - tg.start_soon(server.serve) - - await _wait_until_listening(server) - - async with httpx2.AsyncClient() as client: - response = await client.get( - f"http://127.0.0.1:{port}/callback", - params={ - "code": "good", - "state": "s1", - "iss": "https://issuer.example.com", - }, - ) - assert response.status_code == 200 - - await result_ready.wait() - - assert result.error is None - assert result.code == "good" - assert result.state == "s1" - assert result.iss == "https://issuer.example.com" - - tg.cancel_scope.cancel() - - -async def test_oauth_callback_result_captures_iss_on_error(): - """RFC 9207 applies to error redirects too -- the server emits `iss` on - them, so the callback server must not silently drop it while building the - error result. - """ - port = find_available_port() - result = OAuthCallbackResult() - result_ready = anyio.Event() - server = create_oauth_callback_server( - port=port, - result_container=result, - result_ready=result_ready, - ) - - async with anyio.create_task_group() as tg: - tg.start_soon(server.serve) - - await _wait_until_listening(server) - - async with httpx2.AsyncClient() as client: - response = await client.get( - f"http://127.0.0.1:{port}/callback", - params={ - "error": "access_denied", - "state": "s1", - "iss": "https://issuer.example.com", - }, - ) - assert response.status_code == 400 - - await result_ready.wait() - - assert result.error is not None - assert result.iss == "https://issuer.example.com" - - tg.cancel_scope.cancel() diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py index 10fd47632..d3bc7d5ca 100644 --- a/tests/client/test_roots.py +++ b/tests/client/test_roots.py @@ -1,25 +1,16 @@ -import functools - import pytest -from mcp_types import Root from fastmcp import Client, Context, FastMCP @pytest.fixture def fastmcp_server(): - """A server that issues a handshake-era `roots/list` request. - - `Context` has no `list_roots()` — server-initiated requests are not part of - FastMCP's server API. This server reaches the SDK session directly to stand - in for a legacy upstream, so the client's `roots=` handling stays covered. - """ mcp = FastMCP() @mcp.tool async def list_roots(context: Context) -> list[str]: - result = await context.session.list_roots() # ty: ignore[deprecated] - return [str(r.uri) for r in result.roots] + roots = await context.list_roots() + return [str(r.uri) for r in roots] return mcp @@ -45,62 +36,9 @@ class TestClientRoots: @pytest.mark.parametrize("roots", [["file://x/y/z", "file://x/y/z"]]) async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]): - # `roots/list` is a server-initiated request, so it only exists on the - # handshake era; SEP-2577 removed it from the modern protocol. - async with Client(fastmcp_server, mode="legacy", roots=roots) as client: + async with Client(fastmcp_server, roots=roots) as client: result = await client.call_tool("list_roots", {}) assert result.data == [ "file://x/y/z", "file://x/y/z", ] - - async def test_roots_handler_answers_a_legacy_server(self, fastmcp_server: FastMCP): - """A callable `roots=` handler still answers a legacy server's request.""" - calls: list[object] = [] - - async def roots_handler(ctx) -> list[Root]: - calls.append(ctx) - return [Root(uri="file://from/handler")] - - async with Client(fastmcp_server, mode="legacy", roots=roots_handler) as client: - result = await client.call_tool("list_roots", {}) - - assert len(calls) == 1 - assert result.data == ["file://from/handler"] - - async def test_bound_method_roots_handler(self, fastmcp_server: FastMCP): - class RootsProvider: - async def get_roots(self, _context: object) -> list[str]: - return ["file:///bound-method"] - - provider = RootsProvider() - - async with Client( - fastmcp_server, mode="legacy", roots=provider.get_roots - ) as client: - result = await client.call_tool("list_roots", {}) - - assert result.data == ["file:///bound-method"] - - async def test_partial_roots_handler(self, fastmcp_server: FastMCP): - async def get_roots(prefix: str, _context: object) -> list[str]: - return [f"file:///{prefix}"] - - handler = functools.partial(get_roots, "partial") - - async with Client(fastmcp_server, mode="legacy", roots=handler) as client: - result = await client.call_tool("list_roots", {}) - - assert result.data == ["file:///partial"] - - async def test_callable_object_roots_handler(self, fastmcp_server: FastMCP): - class RootsProvider: - async def __call__(self, _context: object) -> list[str]: - return ["file:///callable-object"] - - async with Client( - fastmcp_server, mode="legacy", roots=RootsProvider() - ) as client: - result = await client.call_tool("list_roots", {}) - - assert result.data == ["file:///callable-object"] diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py index f442419a9..2fc75e311 100644 --- a/tests/client/test_sampling.py +++ b/tests/client/test_sampling.py @@ -1,97 +1,71 @@ import json +from typing import cast +from unittest.mock import AsyncMock -import mcp_types import pytest from mcp_types import TextContent from pydantic_core import to_json from fastmcp import Client, Context, FastMCP from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams +from fastmcp.server.sampling import SamplingResult, SamplingTool from fastmcp.utilities.types import Image -async def _sample( - context: Context, - messages: list[SamplingMessage], - *, - system_prompt: str | None = None, -) -> str: - """Issue a handshake-era `sampling/createMessage` request from a server. - - `Context` has no `sample()` — server-initiated sampling is not part of - FastMCP's server API. These tests cover the *client* side, which must keep - answering a legacy server, so the stand-in server reaches the SDK session - directly. - """ - result = await context.session.create_message( # ty: ignore[deprecated] - messages=messages, - system_prompt=system_prompt, - max_tokens=512, - related_request_id=context.origin_request_id, - ) - assert isinstance(result.content, TextContent) - return result.content.text - - @pytest.fixture def fastmcp_server(): mcp = FastMCP() @mcp.tool async def simple_sample(message: str, context: Context) -> str: - return await _sample( - context, - [ - SamplingMessage( - role="user", - content=TextContent(type="text", text="Hello, world!"), - ) - ], - ) + result = await context.sample("Hello, world!") + assert isinstance(result, SamplingResult) + assert result.text is not None + return result.text @mcp.tool async def sample_with_system_prompt(message: str, context: Context) -> str: - return await _sample( - context, - [ - SamplingMessage( - role="user", - content=TextContent(type="text", text="Hello, world!"), - ) - ], - system_prompt="You love FastMCP", - ) + result = await context.sample("Hello, world!", system_prompt="You love FastMCP") + assert isinstance(result, SamplingResult) + assert result.text is not None + return result.text @mcp.tool async def sample_with_messages(message: str, context: Context) -> str: - return await _sample( - context, + result = await context.sample( [ + "Hello!", SamplingMessage( - role="user", content=TextContent(type="text", text="Hello!") - ), - SamplingMessage( - role="assistant", content=TextContent( type="text", text="How can I assist you today?" ), + role="assistant", ), - ], + ] ) + assert isinstance(result, SamplingResult) + assert result.text is not None + return result.text @mcp.tool async def sample_with_image(image_bytes: bytes, context: Context) -> str: image = Image(data=image_bytes) - return await _sample( - context, + + result = await context.sample( [ SamplingMessage( content=TextContent(type="text", text="What's in this image?"), role="user", ), - SamplingMessage(content=image.to_image_content(), role="user"), - ], + SamplingMessage( + content=image.to_image_content(), + role="user", + ), + ] ) + assert isinstance(result, SamplingResult) + assert result.text is not None + return result.text return mcp @@ -102,9 +76,7 @@ async def test_simple_sampling(fastmcp_server: FastMCP): ) -> str: return "This is the sample message!" - async with Client( - fastmcp_server, mode="legacy", sampling_handler=sampling_handler - ) as client: + async with Client(fastmcp_server, sampling_handler=sampling_handler) as client: result = await client.call_tool("simple_sample", {"message": "Hello, world!"}) assert result.data == "This is the sample message!" @@ -116,9 +88,7 @@ async def test_sampling_with_system_prompt(fastmcp_server: FastMCP): assert params.system_prompt is not None return params.system_prompt - async with Client( - fastmcp_server, mode="legacy", sampling_handler=sampling_handler - ) as client: + async with Client(fastmcp_server, sampling_handler=sampling_handler) as client: result = await client.call_tool( "sample_with_system_prompt", {"message": "Hello, world!"} ) @@ -140,15 +110,33 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP): assert messages[1].content.text == "How can I assist you today?" return "I need to think." - async with Client( - fastmcp_server, mode="legacy", sampling_handler=sampling_handler - ) as client: + async with Client(fastmcp_server, sampling_handler=sampling_handler) as client: result = await client.call_tool( "sample_with_messages", {"message": "Hello, world!"} ) assert result.data == "I need to think." +async def test_sampling_with_fallback(fastmcp_server: FastMCP): + openai_sampling_handler = AsyncMock(return_value="But I need to think") + + fastmcp_server = FastMCP( + sampling_handler=openai_sampling_handler, + ) + + @fastmcp_server.tool + async def sample_with_fallback(context: Context) -> str: + sampling_result = await context.sample("Do not think.") + return cast(TextContent, sampling_result).text + + client = Client(fastmcp_server) + + async with client: + call_tool_result = await client.call_tool("sample_with_fallback") + + assert call_tool_result.data == "But I need to think" + + async def test_sampling_with_image(fastmcp_server: FastMCP): def sampling_handler( messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext @@ -156,9 +144,7 @@ async def test_sampling_with_image(fastmcp_server: FastMCP): assert len(messages) == 2 return to_json(messages).decode() - async with Client( - fastmcp_server, mode="legacy", sampling_handler=sampling_handler - ) as client: + async with Client(fastmcp_server, sampling_handler=sampling_handler) as client: image_bytes = b"abc123" result = await client.call_tool( "sample_with_image", {"image_bytes": image_bytes} @@ -199,6 +185,8 @@ class TestSamplingDefaultCapabilities: {"sampling": {"tools": {}}}, ensuring compatibility with servers that don't recognize the tools sub-field (e.g. older Java MCP SDK). """ + import mcp_types + server = FastMCP() def handler( @@ -213,6 +201,8 @@ class TestSamplingDefaultCapabilities: async def test_set_sampling_callback_default_capabilities_omit_tools(self): """set_sampling_callback should also default to no tools capability.""" + import mcp_types + server = FastMCP() client = Client(server) client.set_sampling_callback(lambda msgs, params, ctx: "ok") @@ -222,6 +212,8 @@ class TestSamplingDefaultCapabilities: async def test_explicit_tools_capability_is_preserved(self): """Explicitly passing tools capability should be respected.""" + import mcp_types + server = FastMCP() def handler( @@ -238,3 +230,117 @@ class TestSamplingDefaultCapabilities: caps = client._session_kwargs["sampling_capabilities"] assert isinstance(caps, mcp_types.SamplingCapability) assert caps.tools is not None + + +class TestSamplingWithTools: + """Tests for sampling with tools functionality.""" + + async def test_sampling_with_tools_requires_capability(self): + """Test that sampling with tools raises error when client lacks capability.""" + import mcp_types + + from fastmcp.exceptions import ToolError + + server = FastMCP() + + def search(query: str) -> str: + """Search the web.""" + return f"Results for: {query}" + + @server.tool + async def sample_with_tool(context: Context) -> str: + # This should fail because the client doesn't advertise tools capability + result = await context.sample( + messages="Search for Python tutorials", + tools=[search], + ) + return str(result) + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> str: + return "Response" + + # Explicitly disable tools capability by passing SamplingCapability without tools + async with Client( + server, + sampling_handler=sampling_handler, + sampling_capabilities=mcp_types.SamplingCapability(), # No tools + ) as client: + with pytest.raises(ToolError, match="sampling.tools capability"): + await client.call_tool("sample_with_tool", {}) + + async def test_sampling_with_tools_fallback_handler_can_return_string(self): + """Test that fallback handler can return a string even when tools are provided. + + The LLM might choose not to use any tools and just return a text response. + """ + # This handler returns a string - valid even when tools are provided + simple_handler = AsyncMock(return_value="Direct response without tools") + + mcp = FastMCP(sampling_handler=simple_handler) + + def search(query: str) -> str: + """Search the web.""" + return f"Results for: {query}" + + @mcp.tool + async def sample_with_tool(context: Context) -> str: + result = await context.sample( + messages="Search for Python tutorials", + tools=[search], + ) + return result.text or "no text" + + # Client without sampling handler - will use server's fallback + async with Client(mcp) as client: + result = await client.call_tool("sample_with_tool", {}) + + # Handler returned string directly, which is treated as final text response + assert result.data == "Direct response without tools" + + def test_sampling_tool_schema(self): + """Test that SamplingTool generates correct schema.""" + + def search(query: str, limit: int = 10) -> str: + """Search the web for results.""" + return f"Results for: {query}" + + tool = SamplingTool.from_function(search) + assert tool.name == "search" + assert tool.description == "Search the web for results." + assert "query" in tool.parameters.get("properties", {}) + assert "limit" in tool.parameters.get("properties", {}) + + async def test_sampling_tool_run(self): + """Test that SamplingTool.run() executes correctly.""" + + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + tool = SamplingTool.from_function(add) + result = await tool.run({"a": 5, "b": 3}) + assert result == 8 + + async def test_sampling_tool_run_async(self): + """Test that SamplingTool.run() works with async functions.""" + + async def async_multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b + + tool = SamplingTool.from_function(async_multiply) + result = await tool.run({"a": 4, "b": 7}) + assert result == 28 + + def test_tool_choice_parameter(self): + """Test that tool_choice parameter accepts string literals.""" + from fastmcp.server.context import ToolChoiceOption + + # Verify ToolChoiceOption type accepts the valid string values + choices: list[ToolChoiceOption] = ["auto", "required", "none"] + assert len(choices) == 3 + assert "auto" in choices + assert "required" in choices + assert "none" in choices diff --git a/tests/client/test_sampling_result_types.py b/tests/client/test_sampling_result_types.py new file mode 100644 index 000000000..e581e2582 --- /dev/null +++ b/tests/client/test_sampling_result_types.py @@ -0,0 +1,681 @@ +import pytest +from mcp_types import CreateMessageResultWithTools, TextContent, ToolUseContent + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams + + +class TestSamplingResultType: + """Tests for result_type parameter (structured output).""" + + async def test_result_type_creates_final_response_tool(self): + """Test that result_type creates a synthetic final_response tool.""" + from mcp_types import CreateMessageResultWithTools, ToolUseContent + from pydantic import BaseModel + + class MathResult(BaseModel): + answer: int + explanation: str + + received_tools: list = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + received_tools.extend(params.tools or []) + + # Return the final_response tool call + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="final_response", + input={"answer": 42, "explanation": "The meaning of life"}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def math_tool(context: Context) -> str: + result = await context.sample( + messages="What is 6 * 7?", + result_type=MathResult, + ) + # result.result should be a MathResult object + assert isinstance(result.result, MathResult) + return f"{result.result.answer}: {result.result.explanation}" + + async with Client(mcp) as client: + result = await client.call_tool("math_tool", {}) + + # Check that final_response tool was added + tool_names = [t.name for t in received_tools] + assert "final_response" in tool_names + + # Check the result + assert result.data == "42: The meaning of life" + + async def test_result_type_with_user_tools(self): + """Test result_type works alongside user-provided tools.""" + from mcp_types import CreateMessageResultWithTools, ToolUseContent + from pydantic import BaseModel + + class SearchResult(BaseModel): + summary: str + sources: list[str] + + def search(query: str) -> str: + """Search for information.""" + return f"Found info about: {query}" + + call_count = 0 + tool_was_called = False + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count, tool_was_called + call_count += 1 + + if call_count == 1: + # First call: use the search tool + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="search", + input={"query": "Python tutorials"}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + else: + # Second call: call final_response + tool_was_called = True + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_2", + name="final_response", + input={ + "summary": "Python is great", + "sources": ["python.org", "docs.python.org"], + }, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def research(context: Context) -> str: + result = await context.sample( + messages="Research Python", + tools=[search], + result_type=SearchResult, + ) + assert isinstance(result.result, SearchResult) + return f"{result.result.summary} - {len(result.result.sources)} sources" + + async with Client(mcp) as client: + result = await client.call_tool("research", {}) + + assert tool_was_called + assert result.data == "Python is great - 2 sources" + + async def test_result_type_validation_error_retries(self): + """Test that validation errors are sent back to LLM for retry.""" + from mcp_types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + from pydantic import BaseModel + + class StrictResult(BaseModel): + value: int # Must be an int + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + # First call: invalid type + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="final_response", + input={"value": "not_an_int"}, # Wrong type + ) + ], + model="test-model", + stop_reason="toolUse", + ) + else: + # Second call: valid type after seeing error + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_2", + name="final_response", + input={"value": 42}, # Correct type + ) + ], + model="test-model", + stop_reason="toolUse", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def validate_tool(context: Context) -> str: + result = await context.sample( + messages="Give me a number", + result_type=StrictResult, + ) + assert isinstance(result.result, StrictResult) + return str(result.result.value) + + async with Client(mcp) as client: + result = await client.call_tool("validate_tool", {}) + + # Should have retried after validation error + assert len(messages_received) == 2 + + # Check that error was passed back + last_messages = messages_received[1] + # Find the tool result in list content + tool_result = None + for msg in last_messages: + # Tool results are now in a list + if isinstance(msg.content, list): + for item in msg.content: + if isinstance(item, ToolResultContent): + tool_result = item + break + elif isinstance(msg.content, ToolResultContent): + tool_result = msg.content + break + assert tool_result is not None + assert tool_result.is_error is True + assert isinstance(tool_result.content[0], TextContent) + error_text = tool_result.content[0].text + assert "Validation error" in error_text + + # Final result should be correct + assert result.data == "42" + + async def test_sampling_result_has_text_and_history(self): + """Test that SamplingResult has text, result, and history attributes.""" + from mcp_types import CreateMessageResultWithTools + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Hello world")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def check_result(context: Context) -> str: + result = await context.sample(messages="Say hello") + # Check all attributes exist + assert result.text == "Hello world" + assert result.result == "Hello world" + assert len(result.history) >= 1 + return "ok" + + async with Client(mcp) as client: + result = await client.call_tool("check_result", {}) + + assert result.data == "ok" + + +class TestSampleStep: + """Tests for ctx.sample_step() - single LLM call with manual control.""" + + async def test_sample_step_basic(self): + """Test basic sample_step returns text response.""" + from mcp_types import CreateMessageResultWithTools + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Hello from step")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_step(context: Context) -> str: + step = await context.sample_step(messages="Hi") + assert not step.is_tool_use + assert step.text == "Hello from step" + return step.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_step", {}) + + assert result.data == "Hello from step" + + async def test_sample_step_with_tool_execution(self): + """Test sample_step executes tools by default.""" + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + call_count = 0 + + def my_tool(x: int) -> str: + """A test tool.""" + return f"result:{x}" + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="my_tool", + input={"x": 42}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_step(context: Context) -> str: + messages: str | list[SamplingMessage] = "Run tool" + + while True: + step = await context.sample_step(messages=messages, tools=[my_tool]) + + if not step.is_tool_use: + return step.text or "" + + # History should include tool results when execute_tools=True + messages = step.history + + async with Client(mcp) as client: + result = await client.call_tool("test_step", {}) + + assert result.data == "Done" + assert call_count == 2 + + async def test_sample_step_execute_tools_false(self): + """Test sample_step with execute_tools=False doesn't execute tools.""" + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + tool_executed = False + + def my_tool() -> str: + """A test tool.""" + nonlocal tool_executed + tool_executed = True + return "executed" + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="my_tool", + input={}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_step(context: Context) -> str: + step = await context.sample_step( + messages="Run tool", + tools=[my_tool], + execute_tools=False, + ) + assert step.is_tool_use + assert len(step.tool_calls) == 1 + assert step.tool_calls[0].name == "my_tool" + # History should include assistant message but no tool results + assert len(step.history) == 2 # user + assistant + return "ok" + + async with Client(mcp) as client: + result = await client.call_tool("test_step", {}) + + assert result.data == "ok" + assert not tool_executed # Tool should not have been executed + + async def test_sample_step_history_includes_assistant_message(self): + """Test that history includes assistant message when execute_tools=False.""" + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="my_tool", + input={"query": "test"}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + def my_tool(query: str) -> str: + return f"result for {query}" + + @mcp.tool + async def test_step(context: Context) -> str: + step = await context.sample_step( + messages="Search", + tools=[my_tool], + execute_tools=False, + ) + # History should have: user message + assistant message + assert len(step.history) == 2 + assert step.history[0].role == "user" + assert step.history[1].role == "assistant" + return "ok" + + async with Client(mcp) as client: + result = await client.call_tool("test_step", {}) + + assert result.data == "ok" + + +class TestTextResponseRetry: + """Tests for retry logic when LLM returns text instead of calling final_response.""" + + @staticmethod + def _text_reply(text: str = "some text"): + from mcp_types import CreateMessageResultWithTools + + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text=text)], + model="m", + stop_reason="endTurn", + ) + + @staticmethod + def _tool_reply(value: int): + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="c1", + name="final_response", + input={"value": value}, + ) + ], + model="m", + stop_reason="toolUse", + ) + + async def test_text_response_then_success(self): + """Text on first call, final_response on second -- verify call_count == 2.""" + from pydantic import BaseModel + + class R(BaseModel): + value: int + + call_count = 0 + + def handler(messages, params, ctx): + nonlocal call_count + call_count += 1 + return self._text_reply() if call_count == 1 else self._tool_reply(42) + + mcp = FastMCP(sampling_handler=handler) + + @mcp.tool + async def t(context: Context) -> str: + return str((await context.sample(messages="q", result_type=R)).result.value) + + async with Client(mcp) as client: + result = await client.call_tool("t", {}) + + assert call_count == 2 + assert result.data == "42" + + async def test_text_response_exceeds_max_retries(self): + """Always text, never tool -- verify error after _MAX_TEXT_RESPONSE_RETRIES+1 calls.""" + from pydantic import BaseModel + + from fastmcp.exceptions import ToolError + from fastmcp.server.sampling.run import _MAX_TEXT_RESPONSE_RETRIES + + class R(BaseModel): + value: int + + call_count = 0 + + def handler(messages, params, ctx): + nonlocal call_count + call_count += 1 + return self._text_reply() + + mcp = FastMCP(sampling_handler=handler) + + @mcp.tool + async def t(context: Context) -> str: + return str((await context.sample(messages="q", result_type=R)).result) + + async with Client(mcp) as client: + with pytest.raises(ToolError, match="attempts"): + await client.call_tool("t", {}) + + assert call_count == _MAX_TEXT_RESPONSE_RETRIES + 1 + + async def test_no_retry_when_result_type_is_none(self): + """Text response with no result_type -- single call, normal return.""" + call_count = 0 + + def handler(messages, params, ctx): + nonlocal call_count + call_count += 1 + return self._text_reply("hello") + + mcp = FastMCP(sampling_handler=handler) + + @mcp.tool + async def t(context: Context) -> str: + return (await context.sample(messages="q")).text or "" + + async with Client(mcp) as client: + result = await client.call_tool("t", {}) + + assert call_count == 1 + assert result.data == "hello" + + +def _final_response(call_id: str, input_data: dict) -> CreateMessageResultWithTools: + """Build a final_response tool-use reply.""" + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", id=call_id, name="final_response", input=input_data + ) + ], + model="test-model", + stop_reason="toolUse", + ) + + +def _tool_call( + call_id: str, name: str, input_data: dict +) -> CreateMessageResultWithTools: + """Build a regular tool-use reply.""" + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent(type="tool_use", id=call_id, name=name, input=input_data) + ], + model="test-model", + stop_reason="toolUse", + ) + + +class TestValidationRetryCap: + """Tests for the consecutive validation retry cap (PR #3851).""" + + async def test_validation_failures_within_cap_then_success(self): + """Two consecutive failures followed by a valid response succeeds.""" + from pydantic import BaseModel + + class R(BaseModel): + value: int + + call_count = 0 + + def handler(messages, params, ctx): + nonlocal call_count + call_count += 1 + if call_count <= 2: + return _final_response(f"c{call_count}", {"value": "bad"}) + return _final_response(f"c{call_count}", {"value": 99}) + + mcp = FastMCP(sampling_handler=handler) + + @mcp.tool + async def t(context: Context) -> str: + r = await context.sample(messages="go", result_type=R) + return str(r.result.value) + + async with Client(mcp) as client: + result = await client.call_tool("t", {}) + + assert call_count == 3 + assert result.data == "99" + + async def test_consecutive_validation_failures_exceed_cap(self): + """Always-invalid responses raise ToolError after exceeding the cap.""" + from pydantic import BaseModel + + from fastmcp.exceptions import ToolError + from fastmcp.server.sampling.run import _MAX_VALIDATION_RETRIES + + class R(BaseModel): + value: int + + call_count = 0 + + def handler(messages, params, ctx): + nonlocal call_count + call_count += 1 + return _final_response(f"c{call_count}", {"value": "wrong"}) + + mcp = FastMCP(sampling_handler=handler) + + @mcp.tool + async def t(context: Context) -> str: + return str((await context.sample(messages="go", result_type=R)).result) + + async with Client(mcp) as client: + with pytest.raises(ToolError, match="consecutive"): + await client.call_tool("t", {}) + + # 1 initial attempt + _MAX_VALIDATION_RETRIES retries + assert call_count == _MAX_VALIDATION_RETRIES + 1 + + async def test_validation_counter_resets_after_other_tool_call(self): + """A tool call between validation failures resets the counter.""" + from pydantic import BaseModel + + class R(BaseModel): + value: int + + def helper_tool(x: int) -> str: + """A helper tool.""" + return f"result:{x}" + + call_count = 0 + + def handler(messages, params, ctx): + nonlocal call_count + call_count += 1 + # fail -> other tool (resets counter) -> fail -> succeed + if call_count == 1: + return _final_response("c1", {"value": "bad"}) + if call_count == 2: + return _tool_call("c2", "helper_tool", {"x": 1}) + if call_count == 3: + return _final_response("c3", {"value": "bad"}) + return _final_response("c4", {"value": 42}) + + mcp = FastMCP(sampling_handler=handler) + + @mcp.tool + async def t(context: Context) -> str: + r = await context.sample(messages="go", tools=[helper_tool], result_type=R) + return str(r.result.value) + + async with Client(mcp) as client: + result = await client.call_tool("t", {}) + + assert call_count == 4 + assert result.data == "42" diff --git a/tests/client/test_sampling_tool_loop.py b/tests/client/test_sampling_tool_loop.py new file mode 100644 index 000000000..809e58ed8 --- /dev/null +++ b/tests/client/test_sampling_tool_loop.py @@ -0,0 +1,769 @@ +from typing import cast + +from mcp_types import TextContent + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams +from fastmcp.server.sampling import SamplingTool + + +class TestAutomaticToolLoop: + """Tests for automatic tool execution loop in ctx.sample().""" + + async def test_automatic_tool_loop_executes_tools(self): + """Test that ctx.sample() automatically executes tool calls.""" + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + call_count = 0 + tool_was_called = False + + def get_weather(city: str) -> str: + """Get weather for a city.""" + nonlocal tool_was_called + tool_was_called = True + return f"Weather in {city}: sunny, 72°F" + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + # First call: return tool use + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="get_weather", + input={"city": "Seattle"}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + else: + # Second call: return final response + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="The weather is sunny!")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def weather_assistant(question: str, context: Context) -> str: + result = await context.sample( + messages=question, + tools=[get_weather], + ) + # Get text from SamplingResult + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool( + "weather_assistant", {"question": "What's the weather?"} + ) + + assert tool_was_called + assert call_count == 2 + assert result.data == "The weather is sunny!" + + async def test_automatic_tool_loop_multiple_tools(self): + """Test that multiple tool calls in one response are all executed.""" + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + executed_tools: list[str] = [] + + def tool_a(x: int) -> int: + """Tool A.""" + executed_tools.append(f"tool_a({x})") + return x * 2 + + def tool_b(y: int) -> int: + """Tool B.""" + executed_tools.append(f"tool_b({y})") + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + # Return multiple tool calls + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", id="call_a", name="tool_a", input={"x": 5} + ), + ToolUseContent( + type="tool_use", id="call_b", name="tool_b", input={"y": 3} + ), + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def multi_tool(context: Context) -> str: + result = await context.sample(messages="Run tools", tools=[tool_a, tool_b]) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("multi_tool", {}) + + assert executed_tools == ["tool_a(5)", "tool_b(3)"] + assert result.data == "Done!" + + async def test_automatic_tool_loop_handles_unknown_tool(self): + """Test that unknown tool names result in error being passed to LLM.""" + from mcp_types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + + def known_tool() -> str: + """A known tool.""" + return "known result" + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + # Request unknown tool + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="unknown_tool", + input={}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Handled error")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_unknown(context: Context) -> str: + result = await context.sample(messages="Test", tools=[known_tool]) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_unknown", {}) + + # Check that error was passed back in messages + assert len(messages_received) == 2 + last_messages = messages_received[1] + # Find the tool result in list content + tool_result = None + for msg in last_messages: + # Tool results are now in a list + if isinstance(msg.content, list): + for item in msg.content: + if isinstance(item, ToolResultContent): + tool_result = item + break + elif isinstance(msg.content, ToolResultContent): + tool_result = msg.content + break + assert tool_result is not None + assert tool_result.is_error is True + # Content is list of TextContent objects + assert isinstance(tool_result.content[0], TextContent) + error_text = tool_result.content[0].text + assert "Unknown tool" in error_text + assert result.data == "Handled error" + + async def test_automatic_tool_loop_handles_tool_exception(self): + """Test that tool exceptions are caught and passed to LLM as errors.""" + from mcp_types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + + def failing_tool() -> str: + """A tool that raises an exception.""" + raise ValueError("Tool failed intentionally") + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="failing_tool", + input={}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Handled error")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_exception(context: Context) -> str: + result = await context.sample(messages="Test", tools=[failing_tool]) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_exception", {}) + + # Check that error was passed back + assert len(messages_received) == 2 + last_messages = messages_received[1] + # Find the tool result in list content + tool_result = None + for msg in last_messages: + # Tool results are now in a list + if isinstance(msg.content, list): + for item in msg.content: + if isinstance(item, ToolResultContent): + tool_result = item + break + elif isinstance(msg.content, ToolResultContent): + tool_result = msg.content + break + assert tool_result is not None + assert tool_result.is_error is True + # Content is list of TextContent objects + assert isinstance(tool_result.content[0], TextContent) + error_text = tool_result.content[0].text + assert "Tool failed intentionally" in error_text + assert result.data == "Handled error" + + async def test_concurrent_tool_execution_default_sequential(self): + """Test that tools execute sequentially by default.""" + import asyncio + import time + + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def slow_tool_a(x: int) -> int: + """Slow tool A.""" + start = time.time() + execution_order.append(("tool_a_start", start)) + await asyncio.sleep(0.1) + execution_order.append(("tool_a_end", time.time())) + return x * 2 + + async def slow_tool_b(y: int) -> int: + """Slow tool B.""" + start = time.time() + execution_order.append(("tool_b_start", start)) + await asyncio.sleep(0.1) + execution_order.append(("tool_b_end", time.time())) + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_a", + name="slow_tool_a", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_b", + name="slow_tool_b", + input={"y": 3}, + ), + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool_a, slow_tool_b], + # Default: tool_concurrency=None (sequential) + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify sequential execution: tool_a must complete before tool_b starts + events = [e[0] for e in execution_order] + assert events == ["tool_a_start", "tool_a_end", "tool_b_start", "tool_b_end"] + + async def test_concurrent_tool_execution_unlimited(self): + """Test unlimited parallel tool execution with tool_concurrency=0.""" + import asyncio + import time + + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + execution_times: dict[str, dict[str, float]] = {} + + async def slow_tool_a(x: int) -> int: + """Slow tool A.""" + execution_times["tool_a"] = {"start": time.time()} + await asyncio.sleep(0.1) + execution_times["tool_a"]["end"] = time.time() + return x * 2 + + async def slow_tool_b(y: int) -> int: + """Slow tool B.""" + execution_times["tool_b"] = {"start": time.time()} + await asyncio.sleep(0.1) + execution_times["tool_b"]["end"] = time.time() + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_a", + name="slow_tool_a", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_b", + name="slow_tool_b", + input={"y": 3}, + ), + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool_a, slow_tool_b], + tool_concurrency=0, # Unlimited parallel + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify parallel execution: both tools should overlap in time + assert "tool_a" in execution_times + assert "tool_b" in execution_times + # tool_b should start before tool_a finishes (overlap) + assert execution_times["tool_b"]["start"] < execution_times["tool_a"]["end"] + + async def test_concurrent_tool_execution_bounded(self): + """Test bounded parallel execution with tool_concurrency=2.""" + import asyncio + import time + + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def slow_tool(name: str, duration: float = 0.1) -> str: + """Generic slow tool.""" + execution_order.append((f"{name}_start", time.time())) + await asyncio.sleep(duration) + execution_order.append((f"{name}_end", time.time())) + return f"{name} done" + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + # Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd) + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="slow_tool", + input={"name": "tool_1", "duration": 0.1}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="slow_tool", + input={"name": "tool_2", "duration": 0.1}, + ), + ToolUseContent( + type="tool_use", + id="call_3", + name="slow_tool", + input={"name": "tool_3", "duration": 0.05}, + ), + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool], + tool_concurrency=2, # Max 2 concurrent + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify that at most 2 tools run concurrently + events = [e[0] for e in execution_order] + # First 2 tools should start before either ends + assert events[0] in ["tool_1_start", "tool_2_start"] + assert events[1] in ["tool_1_start", "tool_2_start"] + # Third tool should start after at least one of the first two finishes + tool_3_start_idx = events.index("tool_3_start") + assert ( + "tool_1_end" in events[:tool_3_start_idx] + or "tool_2_end" in events[:tool_3_start_idx] + ) + + async def test_sequential_tool_forces_sequential_execution(self): + """Test that sequential=True forces all tools to execute sequentially.""" + import asyncio + import time + + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def normal_tool(x: int) -> int: + """Normal tool.""" + execution_order.append(("normal_start", time.time())) + await asyncio.sleep(0.05) + execution_order.append(("normal_end", time.time())) + return x * 2 + + async def sequential_tool(y: int) -> int: + """Sequential tool.""" + execution_order.append(("sequential_start", time.time())) + await asyncio.sleep(0.05) + execution_order.append(("sequential_end", time.time())) + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="normal_tool", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="sequential_tool", + input={"y": 3}, + ), + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + # Create tools with sequential=True for one of them + normal = SamplingTool.from_function(normal_tool, sequential=False) + sequential = SamplingTool.from_function(sequential_tool, sequential=True) + + result = await context.sample( + messages="Run tools", + tools=[normal, sequential], + tool_concurrency=0, # Request unlimited, but sequential tool forces sequential + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify sequential execution: first tool must complete before second starts + events = [e[0] for e in execution_order] + assert events[0] in ["normal_start", "sequential_start"] + assert events[1] in ["normal_end", "sequential_end"] + # Ensure the second tool starts after the first ends + if events[0] == "normal_start": + assert events[1] == "normal_end" + assert events[2] == "sequential_start" + else: + assert events[1] == "sequential_end" + assert events[2] == "normal_start" + + async def test_concurrent_tool_execution_error_handling(self): + """Test that errors are captured per-tool in parallel execution.""" + from mcp_types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + + def good_tool() -> str: + return "success" + + def bad_tool() -> str: + raise ValueError("Tool error") + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", id="call_1", name="good_tool", input={} + ), + ToolUseContent( + type="tool_use", id="call_2", name="bad_tool", input={} + ), + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Handled errors")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[good_tool, bad_tool], + tool_concurrency=0, # Parallel execution + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Handled errors" + # Check that tool results include both success and error + tool_result_message = messages_received[1][-1] + assert tool_result_message.role == "user" + tool_results = cast(list[ToolResultContent], tool_result_message.content) + assert len(tool_results) == 2 + # One should be success, one should be error + assert any(not r.is_error for r in tool_results) + assert any(r.is_error for r in tool_results) + + async def test_concurrent_tool_result_order_preserved(self): + """Test that tool results maintain the same order as tool calls.""" + import asyncio + + from mcp_types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + + async def tool_with_delay(value: int, delay: float) -> int: + """Tool that takes variable time.""" + await asyncio.sleep(delay) + return value + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + # Tools with different delays - later tools finish first + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="tool_with_delay", + input={"value": 1, "delay": 0.15}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="tool_with_delay", + input={"value": 2, "delay": 0.05}, + ), + ToolUseContent( + type="tool_use", + id="call_3", + name="tool_with_delay", + input={"value": 3, "delay": 0.1}, + ), + ], + model="test-model", + stop_reason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[tool_with_delay], + tool_concurrency=0, # Parallel execution + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1) + tool_result_message = messages_received[1][-1] + tool_results = cast(list[ToolResultContent], tool_result_message.content) + assert len(tool_results) == 3 + assert tool_results[0].tool_use_id == "call_1" + assert tool_results[1].tool_use_id == "call_2" + assert tool_results[2].tool_use_id == "call_3" + # Check values are correct + result_texts = [cast(TextContent, r.content[0]).text for r in tool_results] + assert result_texts == ["1", "2", "3"] diff --git a/tests/client/test_slim_package_boundaries.py b/tests/client/test_slim_package_boundaries.py index 187bf177a..5f59b6b3c 100644 --- a/tests/client/test_slim_package_boundaries.py +++ b/tests/client/test_slim_package_boundaries.py @@ -64,7 +64,6 @@ async def test_multiserver_config_requires_server_for_now() -> None: pass -@pytest.mark.subprocess_heavy def test_bare_slim_import_needs_only_mcp_types() -> None: """A bare `fastmcp-slim` install ships `mcp-types` but not the full `mcp` SDK. diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 15a8bda34..eb94faa75 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -11,7 +11,7 @@ from fastmcp.client.transports import SSETransport from fastmcp.server.dependencies import get_http_request from fastmcp.server.http import create_sse_app from fastmcp.server.server import FastMCP -from fastmcp.utilities.tests import ASGIServer, asgi_server +from fastmcp.utilities.tests import run_server_async def create_test_server() -> FastMCP: @@ -63,22 +63,24 @@ def create_test_server() -> FastMCP: @pytest.fixture async def sse_server(): - """Start a test server with SSE transport, in-process.""" + """Start a test server with SSE transport and return its URL.""" server = create_test_server() - async with asgi_server(server, transport="sse") as running_server: - yield running_server + async with run_server_async(server, transport="sse") as url: + yield url -async def test_ping(sse_server: ASGIServer): +async def test_ping(sse_server: str): """Test pinging the server.""" - async with sse_server.client() as client: + async with Client(transport=SSETransport(sse_server)) as client: result = await client.ping() assert result is True -async def test_http_headers(sse_server: ASGIServer): +async def test_http_headers(sse_server: str): """Test getting HTTP headers from the server.""" - async with sse_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client: + async with Client( + transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + ) as client: raw_result = await client.read_resource("request://headers") assert isinstance(raw_result[0], TextResourceContents) json_result = json.loads(raw_result[0].text) @@ -88,10 +90,10 @@ async def test_http_headers(sse_server: ASGIServer): @pytest.fixture async def sse_server_custom_path(): - """Start a test server with SSE on a custom path, in-process.""" + """Start a test server with SSE on a custom path.""" server = create_test_server() - async with asgi_server(server, transport="sse", path="/help") as running_server: - yield running_server + async with run_server_async(server, transport="sse", path="/help") as url: + yield url @pytest.fixture @@ -138,9 +140,9 @@ async def nested_sse_server(): pass -async def test_run_server_on_path(sse_server_custom_path: ASGIServer): +async def test_run_server_on_path(sse_server_custom_path: str): """Test running server on a custom path.""" - async with sse_server_custom_path.client() as client: + async with Client(transport=SSETransport(sse_server_custom_path)) as client: result = await client.ping() assert result is True @@ -157,33 +159,42 @@ async def test_nested_sse_server_resolves_correctly(nested_sse_server: str): reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", ) class TestTimeout: - async def test_timeout(self, sse_server: ASGIServer): + async def test_timeout(self, sse_server: str): with pytest.raises( MCPError, match="timed out", ): - async with sse_server.client(timeout=0.03) as client: + async with Client( + transport=SSETransport(sse_server), + timeout=0.03, + ) as client: await client.call_tool("sleep", {"seconds": 0.1}) - async def test_timeout_tool_call(self, sse_server: ASGIServer): - async with sse_server.client() as client: + async def test_timeout_tool_call(self, sse_server: str): + async with Client(transport=SSETransport(sse_server)) as client: with pytest.raises(MCPError, match="timed out"): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03) async def test_timeout_tool_call_overrides_client_timeout_if_lower( - self, sse_server: ASGIServer + self, sse_server: str ): - async with sse_server.client(timeout=2) as client: + async with Client( + transport=SSETransport(sse_server), + timeout=2, + ) as client: with pytest.raises(MCPError, match="timed out"): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03) async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower( - self, sse_server: ASGIServer + self, sse_server: str ): """ With SSE, the tool call timeout always takes precedence over the client. Note: on Windows, the behavior appears unpredictable. """ - async with sse_server.client(timeout=0.5) as client: + async with Client( + transport=SSETransport(sse_server), + timeout=0.5, + ) as client: await client.call_tool("sleep", {"seconds": 0.8}, timeout=2) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 09370c62b..af4995203 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -2,23 +2,13 @@ import asyncio import gc import inspect import os -import time import weakref -from pathlib import Path import psutil import pytest -from mcp.shared.exceptions import MCPError from fastmcp import Client from fastmcp.client.transports import PythonStdioTransport, StdioTransport -from fastmcp.exceptions import FastMCPError - -# A pure-stdlib MCP server used by the process-lifecycle tests below. It starts -# in ~0.03s instead of the ~0.7s a real FastMCP server needs, which matters -# because these tests spawn several subprocesses each. See its docstring for -# what it does and does not implement. -MINIMAL_STDIO_SERVER = Path(__file__).parent / "minimal_stdio_server.py" def running_under_debugger(): @@ -34,145 +24,26 @@ def gc_collect_harder(): gc.collect() -async def wait_for_log_content( - log_file_path, expected: str, timeout: float = 2.0 -) -> str: - """Poll a log file until it contains the expected text. - - The subprocess's stderr is redirected straight to the file at the OS - level (no async pump on our side to synchronize on), so poll for the - content instead of sleeping a fixed amount and hoping it landed. - """ - - async def _poll() -> str: - while True: - content = log_file_path.read_text() - if expected in content: - return content - await asyncio.sleep(0.01) - - return await asyncio.wait_for(_poll(), timeout=timeout) - - -async def wait_for_process_exit(pid: int | None, timeout: float = 5.0) -> None: - """Poll until the given pid is gone, failing clearly if it never exits. - - The subprocesses under test self-terminate within a fraction of a second, - so a bounded poll costs nothing and turns a hung teardown into a named - failure instead of an opaque suite-level timeout. - """ - assert pid is not None - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - psutil.Process(pid) - except psutil.NoSuchProcess: - return - await asyncio.sleep(0.01) - pytest.fail(f"Subprocess {pid} was still alive after {timeout}s") - - -# Exceptions a call may raise while a crashed stdio session is being torn down -# and replaced. The direct-client path surfaces MCPError (session closed) or -# RuntimeError (reconnect failed); a proxy wraps the backend failure in a -# FastMCPError (e.g. ToolError). -CRASH_RECOVERY_EXCEPTIONS = (MCPError, RuntimeError, FastMCPError) - - -async def _recover_new_pid(call, old_pid: int, *, attempts: int = 10) -> int: - """Call `call` until it succeeds on a subprocess other than `old_pid`. - - `psutil.Process(pid).kill()` is asynchronous and crash recovery is - transparent, so a post-crash call may raise while the stale session is torn - down, briefly still reach the dying old process, or land on a fresh - subprocess — the outcome depends on scheduling. Retrying until a call - succeeds on a *new* pid asserts eventual recovery instead of the exact - number of failed calls, which was never an actual contract. - """ - last_exc: BaseException | None = None - for _ in range(attempts): - try: - pid = await call() - except CRASH_RECOVERY_EXCEPTIONS as exc: - last_exc = exc - else: - if pid != old_pid: - return pid - await asyncio.sleep(0.05) - raise AssertionError( - f"stdio backend never recovered onto a new subprocess after {attempts} attempts" - ) from last_exc - - -async def recover_client_pid(client: Client, old_pid: int, **kwargs) -> int: - """Reconnect `client` and return the pid of the freshly spawned subprocess.""" - - async def call() -> int: - async with client: - result = await client.call_tool("pid") - return int(result.data) - - return await _recover_new_pid(call, old_pid, **kwargs) - - -async def recover_proxy_pid(proxy, old_pid: int, **kwargs) -> int: - """Call the proxy and return the pid of the freshly spawned backend subprocess.""" - - async def call() -> int: - result = await proxy.call_tool("pid") - return int(result.content[0].text) - - return await _recover_new_pid(call, old_pid, **kwargs) - - -class TestDisconnect: - async def test_cancelled_connection_task_is_cleaned_up(self): - transport = StdioTransport(command="python", args=[]) - connect_task = asyncio.create_task(asyncio.sleep(0)) - connect_task.cancel() - transport._connect_task = connect_task - - await transport.disconnect() - - assert transport._connect_task is None - assert not transport._stop_event.is_set() - - async def test_caller_cancellation_is_not_suppressed(self): - transport = StdioTransport(command="python", args=[]) - connection_finished = asyncio.Event() - connect_task = asyncio.create_task(connection_finished.wait()) - transport._connect_task = connect_task - - disconnect_task = asyncio.create_task(transport.disconnect()) - await asyncio.sleep(0) - disconnect_task.cancel() - - with pytest.raises(asyncio.CancelledError): - await disconnect_task - assert not connect_task.cancelled() - - connection_finished.set() - await connect_task - await transport.disconnect() - - async def test_caller_cancellation_wins_when_connection_is_also_cancelled(self): - transport = StdioTransport(command="python", args=[]) - connect_task = asyncio.create_task(asyncio.Event().wait()) - transport._connect_task = connect_task - - disconnect_task = asyncio.create_task(transport.disconnect()) - await asyncio.sleep(0) - connect_task.cancel() - disconnect_task.cancel() - - with pytest.raises(asyncio.CancelledError): - await disconnect_task - - class TestParallelCalls: @pytest.fixture - def stdio_script(self): - return MINIMAL_STDIO_SERVER + def stdio_script(self, tmp_path): + script = inspect.cleandoc(''' + import os + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def pid() -> int: + """Gets PID of server""" + return os.getpid() + + if __name__ == "__main__": + mcp.run() + ''') + script_file = tmp_path / "stdio.py" + script_file.write_text(script) + return script_file async def test_parallel_calls(self, stdio_script): from fastmcp.server import create_proxy @@ -198,8 +69,24 @@ class TestKeepAlive: # https://github.com/PrefectHQ/fastmcp/issues/581 @pytest.fixture - def stdio_script(self): - return MINIMAL_STDIO_SERVER + def stdio_script(self, tmp_path): + script = inspect.cleandoc(''' + import os + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def pid() -> int: + """Gets PID of server""" + return os.getpid() + + if __name__ == "__main__": + mcp.run() + ''') + script_file = tmp_path / "stdio.py" + script_file.write_text(script) + return script_file async def test_keep_alive_default_true(self): client = Client(transport=StdioTransport(command="python", args=[""])) @@ -273,7 +160,10 @@ class TestKeepAlive: # This test may fail/hang while debugging because the debugger holds a reference to the underlying transport - await wait_for_process_exit(pid) + with pytest.raises(psutil.NoSuchProcess): + while True: + psutil.Process(pid) + await asyncio.sleep(0.1) async def test_keep_alive_false_exit_scope_kills_server(self, stdio_script): pid: int | None = None @@ -291,7 +181,10 @@ class TestKeepAlive: await test_server() - await wait_for_process_exit(pid) + with pytest.raises(psutil.NoSuchProcess): + while True: + psutil.Process(pid) + await asyncio.sleep(0.1) async def test_keep_alive_false_starts_new_session_across_multiple_calls( self, stdio_script @@ -375,8 +268,24 @@ class TestSubprocessCrashRecovery: INIT_TIMEOUT = 3 @pytest.fixture - def stdio_script(self): - return MINIMAL_STDIO_SERVER + def stdio_script(self, tmp_path): + script = inspect.cleandoc(''' + import os + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def pid() -> int: + """Gets PID of server""" + return os.getpid() + + if __name__ == "__main__": + mcp.run() + ''') + script_file = tmp_path / "stdio.py" + script_file.write_text(script) + return script_file async def test_keep_alive_recovers_after_subprocess_crash(self, stdio_script): """When keep_alive=True and the subprocess dies, the next connection should start a fresh subprocess.""" @@ -392,10 +301,16 @@ class TestSubprocessCrashRecovery: # Kill the subprocess to simulate a crash psutil.Process(pid1).kill() - # Recovery is transparent: reconnecting eventually lands on a fresh - # subprocess with a new pid, regardless of how many attempts the - # stale-session teardown costs. - pid2 = await recover_client_pid(client, pid1) + # First attempt after crash fails — the stale session is + # detected and torn down so subsequent attempts succeed. + with pytest.raises(Exception): + async with client: + await client.call_tool("pid") + + # Next connection starts a fresh subprocess + async with client: + result2 = await client.call_tool("pid") + pid2: int = result2.data assert pid1 != pid2 @@ -430,19 +345,19 @@ class TestSubprocessCrashRecovery: pids: list[int] = [] for _ in range(3): - if pids: - # After a crash, recovery is transparent — the next working - # call lands on a fresh subprocess with a new pid. - pid = await recover_client_pid(client, pids[-1]) - else: - async with client: - result = await client.call_tool("pid") - pid = result.data - pids.append(pid) + async with client: + result = await client.call_tool("pid") + pid: int = result.data + pids.append(pid) - # Kill the subprocess to force the next cycle to recover + # Kill the subprocess psutil.Process(pid).kill() + # Fail once to trigger cleanup + with pytest.raises(Exception): + async with client: + await client.call_tool("pid") + # Each cycle should have started a new subprocess assert len(set(pids)) == 3 @@ -454,23 +369,21 @@ class TestSubprocessCrashRecovery: ) pid1: int = 0 - try: + with pytest.raises(Exception): async with client: result = await client.call_tool("pid") pid1 = result.data # Kill while the context is still open psutil.Process(pid1).kill() - # This call races the asynchronous kill: it may hit the dead - # session and raise, or briefly still be served. Either outcome - # is fine — what matters is that recovery works afterward. + # This call hits the dead session await client.call_tool("pid") - except CRASH_RECOVERY_EXCEPTIONS: - pass assert pid1 != 0, "First call should have succeeded before the crash" # Recovery: next connection starts a fresh subprocess - pid2 = await recover_client_pid(client, pid1) + async with client: + result = await client.call_tool("pid") + pid2: int = result.data assert pid1 != pid2 @@ -491,9 +404,13 @@ class TestSubprocessCrashRecovery: # Kill the backend subprocess psutil.Process(pid1).kill() - # Recovery is transparent: a call after the crash eventually succeeds on - # a fresh backend subprocess with a new pid. - pid2 = await recover_proxy_pid(proxy, pid1) + # First call after crash fails + with pytest.raises(Exception): + await proxy.call_tool("pid") + + # Second call recovers with a new subprocess + result2 = await proxy.call_tool("pid") + pid2 = int(result2.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] assert pid1 != pid2 @@ -514,77 +431,61 @@ class TestSubprocessCrashRecovery: # Kill the subprocess psutil.Process(pid1).kill() - # Fire several concurrent requests. Depending on how quickly the dead - # session is detected and a replacement spawned, each caller either - # fails cleanly or lands on the fresh subprocess — with a fast-starting - # server, recovery can beat all five requests and the crash is fully - # transparent. What must never happen: a hang (gather returning is the - # proof), or a "success" served by the killed process. + # Fire several concurrent requests — all should fail, none should hang tasks = [proxy.call_tool("pid") for _ in range(5)] results = await asyncio.gather(*tasks, return_exceptions=True) - for r in results: - if not isinstance(r, Exception): - served_by = int(r.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - assert served_by != pid1, ( - "call reported success from the killed subprocess" - ) + errors = [r for r in results if isinstance(r, Exception)] + assert len(errors) > 0 - # Recovery: a subsequent request must succeed on a fresh subprocess + # Recovery: a subsequent request should succeed result = await proxy.call_tool("pid") pid2 = int(result.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] assert pid1 != pid2 - async def test_clean_exit_recovers(self): + async def test_clean_exit_recovers(self, tmp_path): """Recovery works when the subprocess exits cleanly (exit code 0), not just crashes.""" + script = tmp_path / "exit_script.py" + script.write_text( + inspect.cleandoc(''' + import os, sys, threading + from fastmcp import FastMCP + + mcp = FastMCP() + call_count = 0 + + @mcp.tool + def pid_then_exit() -> int: + """Returns PID, exits cleanly after second call.""" + global call_count + call_count += 1 + pid = os.getpid() + if call_count >= 2: + threading.Timer(0.1, lambda: os._exit(0)).start() + return pid + + if __name__ == "__main__": + mcp.run() + ''') + ) + client = Client( - transport=PythonStdioTransport( - script_path=MINIMAL_STDIO_SERVER, - args=["--exit-after-calls", "2"], - ), + transport=PythonStdioTransport(script_path=script), init_timeout=self.INIT_TIMEOUT, ) async with client: - result1 = await client.call_tool("pid") + result1 = await client.call_tool("pid_then_exit") pid1: int = result1.data # Second call triggers delayed clean exit - await client.call_tool("pid") + await client.call_tool("pid_then_exit") + await asyncio.sleep(0.3) - # Wait for the subprocess to actually exit (it self-terminates - # via a background timer ~0.1s after the second call) instead - # of blindly sleeping past the worst case. - await wait_for_process_exit(pid1) + # Recovery after clean exit + async with client: + result2 = await client.call_tool("pid_then_exit") + pid2: int = result2.data - # Recovery after clean exit. - # - # The transport only notices a dead session once the SDK dispatcher's - # read loop has observed EOF on the subprocess's stdout and set its - # `_closed` flag (see `StdioTransport._is_session_dead`). The process - # being gone does not imply that detection has happened yet: EOF has to - # travel from the OS pipe through anyio's stream plumbing and then be - # picked up by a separate read-loop task. On a loaded machine — notably - # Windows CI running xdist workers on two cores — that can land after - # `connect()` samples the flag, so the first attempt is routed to the - # stale session and fails with CONNECTION_CLOSED, which in turn tears - # the session down so the next attempt reconnects. - # - # Like the crash tests above, this asserts eventual recovery rather than - # a fixed number of failed attempts: the failure is timing-dependent, so - # retry instead. The invariant under test is that a cleanly-exited server - # is replaced by a fresh subprocess, not how many attempts EOF detection - # costs. - pid2: int | None = None - for _ in range(2): - try: - async with client: - result2 = await client.call_tool("pid") - pid2 = result2.data - break - except (MCPError, RuntimeError): - continue - - assert pid2 is not None, "Client did not recover after a clean subprocess exit" assert pid1 != pid2 async def test_crash_during_initialization(self, tmp_path): @@ -607,13 +508,20 @@ class TestSubprocessCrashRecovery: async with client: pass - # Replace the same path with a working server. It delegates to the - # minimal stdio server so the retry doesn't pay for a fastmcp import. + # Write a working script to the same path crash_script.write_text( - inspect.cleandoc(f""" - import runpy + inspect.cleandoc(""" + import os + from fastmcp import FastMCP - runpy.run_path({str(MINIMAL_STDIO_SERVER)!r}, run_name="__main__") + mcp = FastMCP() + + @mcp.tool + def pid() -> int: + return os.getpid() + + if __name__ == "__main__": + mcp.run() """) ) @@ -623,16 +531,7 @@ class TestSubprocessCrashRecovery: assert isinstance(result.data, int) -@pytest.mark.subprocess_heavy class TestLogFile: - """Stderr capture, proven against a real FastMCP server. - - Unlike the rest of this module these spawn a full `import fastmcp` - interpreter rather than the minimal stdlib server, because the point is - that the log file captures a real server's stderr. That costs ~0.7s per - spawn, so they run in the serial CI step. - """ - @pytest.fixture def stdio_script_with_stderr(self, tmp_path): script = inspect.cleandoc(''' @@ -695,7 +594,10 @@ class TestLogFile: async with client: await client.call_tool("write_error", {"message": "Test error message"}) - content = await wait_for_log_content(log_file_path, "Test error message") + # Need to wait a bit for stderr to flush + await asyncio.sleep(0.1) + + content = log_file_path.read_text() assert "Test error message" in content async def test_log_file_captures_stderr_output_with_textio( @@ -715,10 +617,10 @@ class TestLogFile: "write_error", {"message": "Test error with TextIO"} ) - content = await wait_for_log_content( - log_file_path, "Test error with TextIO" - ) + # Need to wait a bit for stderr to flush + await asyncio.sleep(0.1) + content = log_file_path.read_text() assert "Test error with TextIO" in content async def test_log_file_none_uses_default_behavior( diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 93ac4526d..f109587e6 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -13,7 +13,7 @@ from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.dependencies import get_http_request from fastmcp.server.server import FastMCP -from fastmcp.utilities.tests import ASGIServer, asgi_server +from fastmcp.utilities.tests import run_server_async def create_test_server() -> FastMCP: @@ -90,8 +90,8 @@ async def streamable_http_server(request): fastmcp.settings.stateless_http = True server = create_test_server() - async with asgi_server(server) as running_server: - yield running_server + async with run_server_async(server) as url: + yield url if stateless_http: fastmcp.settings.stateless_http = False @@ -101,8 +101,8 @@ async def streamable_http_server(request): async def streamable_http_server_with_streamable_http_alias(): """Test that the "streamable-http" transport alias works.""" server = create_test_server() - async with asgi_server(server, transport="streamable-http") as running_server: - yield running_server + async with run_server_async(server, transport="streamable-http") as url: + yield url @pytest.fixture @@ -147,30 +147,34 @@ async def nested_server(): await asyncio.wait_for(server_task, timeout=2.0) -async def test_ping(streamable_http_server: ASGIServer): +async def test_ping(streamable_http_server: str): """Test pinging the server.""" - # `ping` is a handshake-era method, so this pins the legacy era. - async with streamable_http_server.client(mode="legacy") as client: - result = await client.ping() - assert result is True - - -async def test_ping_with_streamable_http_alias( - streamable_http_server_with_streamable_http_alias: ASGIServer, -): - """Test pinging the server.""" - # `ping` is a handshake-era method, so this pins the legacy era. - async with streamable_http_server_with_streamable_http_alias.client( - mode="legacy" + async with Client( + transport=StreamableHttpTransport(streamable_http_server) ) as client: result = await client.ping() assert result is True -async def test_http_headers(streamable_http_server: ASGIServer): +async def test_ping_with_streamable_http_alias( + streamable_http_server_with_streamable_http_alias: str, +): + """Test pinging the server.""" + async with Client( + transport=StreamableHttpTransport( + streamable_http_server_with_streamable_http_alias + ) + ) as client: + result = await client.ping() + assert result is True + + +async def test_http_headers(streamable_http_server: str): """Test getting HTTP headers from the server.""" - async with streamable_http_server.client( - headers={"X-DEMO-HEADER": "ABC"} + async with Client( + transport=StreamableHttpTransport( + streamable_http_server, headers={"X-DEMO-HEADER": "ABC"} + ) ) as client: raw_result = await client.read_resource("request://headers") assert isinstance(raw_result[0], TextResourceContents) @@ -179,22 +183,23 @@ async def test_http_headers(streamable_http_server: ASGIServer): assert json_result["x-demo-header"] == "ABC" -async def test_session_id_callback(streamable_http_server: ASGIServer): +async def test_session_id_callback(streamable_http_server: str): """Test getting mcp-session-id from the transport.""" - transport = streamable_http_server.transport() + transport = StreamableHttpTransport(streamable_http_server) assert transport.get_session_id() is None - async with Client(transport=transport, mode="legacy"): + async with Client(transport=transport): session_id = transport.get_session_id() assert session_id is not None @pytest.mark.parametrize("streamable_http_server", [True, False], indirect=True) -async def test_greet_with_progress_tool(streamable_http_server: ASGIServer): +async def test_greet_with_progress_tool(streamable_http_server: str): """Test calling the greet tool.""" progress_handler = AsyncMock(return_value=None) - async with streamable_http_server.client( - progress_handler=progress_handler + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + progress_handler=progress_handler, ) as client: result = await client.call_tool("greet_with_progress", {"name": "Alice"}) assert result.data == "Hello, Alice!" @@ -208,7 +213,7 @@ async def test_greet_with_progress_tool(streamable_http_server: ASGIServer): @pytest.mark.parametrize("streamable_http_server", [True, False], indirect=True) -async def test_elicitation_tool(streamable_http_server: ASGIServer, request): +async def test_elicitation_tool(streamable_http_server: str, request): """Test calling the elicitation tool in both stateless and stateful modes.""" async def elicitation_handler(message, response_type, params, ctx): @@ -218,37 +223,37 @@ async def test_elicitation_tool(streamable_http_server: ASGIServer, request): if stateless_http: pytest.xfail("Elicitation is not supported in stateless HTTP mode") - # Server-initiated elicitation is handshake-era only. - async with streamable_http_server.client( - elicitation_handler=elicitation_handler, mode="legacy" + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + elicitation_handler=elicitation_handler, ) as client: result = await client.call_tool("elicit") assert result.data == "You said your name was: Alice!" @pytest.mark.parametrize("streamable_http_server", [True], indirect=True) -async def test_stateless_http_rejects_get_sse(streamable_http_server: ASGIServer): +async def test_stateless_http_rejects_get_sse(streamable_http_server: str): """Stateless servers should reject GET SSE requests with 405.""" - async with streamable_http_server.http_client() as http_client: - response = await http_client.get(streamable_http_server.url) + import httpx2 + + async with httpx2.AsyncClient() as http_client: + response = await http_client.get(streamable_http_server) assert response.status_code == 405 @pytest.mark.parametrize("streamable_http_server", [True], indirect=True) -async def test_stateless_http_still_accepts_post( - streamable_http_server: ASGIServer, -): +async def test_stateless_http_still_accepts_post(streamable_http_server: str): """Stateless servers should still handle POST requests normally.""" - async with streamable_http_server.client() as client: + async with Client( + transport=StreamableHttpTransport(streamable_http_server) + ) as client: result = await client.call_tool("greet", {"name": "World"}) assert result.data == "Hello, World!" async def test_nested_streamable_http_server_resolves_correctly(nested_server: str): """Test patch for https://github.com/modelcontextprotocol/python-sdk/pull/659""" - async with Client( - transport=StreamableHttpTransport(nested_server), mode="legacy" - ) as client: + async with Client(transport=StreamableHttpTransport(nested_server)) as client: result = await client.ping() assert result is True @@ -258,21 +263,29 @@ async def test_nested_streamable_http_server_resolves_correctly(nested_server: s reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", ) class TestTimeout: - async def test_timeout(self, streamable_http_server: ASGIServer): + async def test_timeout(self, streamable_http_server: str): # note this transport behaves differently than others and raises # MCPError from the *client* context with pytest.raises(MCPError, match="timed out"): - async with streamable_http_server.client(timeout=0.02) as client: + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + timeout=0.02, + ) as client: await client.call_tool("sleep", {"seconds": 0.05}) - async def test_timeout_tool_call(self, streamable_http_server: ASGIServer): - async with streamable_http_server.client() as client: + async def test_timeout_tool_call(self, streamable_http_server: str): + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + ) as client: with pytest.raises(MCPError): await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1) async def test_timeout_tool_call_overrides_client_timeout( - self, streamable_http_server: ASGIServer + self, streamable_http_server: str ): - async with streamable_http_server.client(timeout=2) as client: + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + timeout=2, + ) as client: with pytest.raises(MCPError): await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1) diff --git a/tests/client/transports/test_memory_transport.py b/tests/client/transports/test_memory_transport.py index 5153ecbf4..e5eab41cd 100644 --- a/tests/client/transports/test_memory_transport.py +++ b/tests/client/transports/test_memory_transport.py @@ -7,12 +7,9 @@ Client(server) with an in-process FastMCP server. import time import pytest -from docket import Docket from fastmcp import Client, FastMCP from fastmcp.client.transports import FastMCPTransport -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import submit_task, wait_for_task def test_transport_repr_includes_server_name(): @@ -21,18 +18,8 @@ def test_transport_repr_includes_server_name(): assert repr(transport) == "<FastMCPTransport(server='repr-test')>" -@pytest.fixture -def reset_docket_memory_server(): - """Force a fresh memory:// Docket server bound to this test's loop.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - @pytest.mark.timeout(10) -async def test_task_teardown_does_not_hang(reset_docket_memory_server): +async def test_task_teardown_does_not_hang(): """In-memory transport must tear down in under 2 seconds after a task call. This is a regression test for a teardown ordering bug where the Docket @@ -51,13 +38,8 @@ async def test_task_teardown_does_not_hang(reset_docket_memory_server): If this test takes ~5 seconds, the context manager nesting in FastMCPTransport.connect_session() has been reversed — the lifespan must be the OUTER context and the task group must be the INNER context. - - There is no client task-submission API yet (Phase 4), so the task is - driven server-side within the live in-memory session; the teardown path - being exercised is the same either way. """ mcp = FastMCP("teardown-test") - mcp.add_extension(TasksExtension()) @mcp.tool(task=True) async def fast_tool(x: int) -> int: @@ -65,12 +47,10 @@ async def test_task_teardown_does_not_hang(reset_docket_memory_server): t0 = time.monotonic() - async with Client(mcp): - created = await submit_task(mcp, "fast_tool", {"x": 21}) - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": 42} + async with Client(mcp) as client: + task = await client.call_tool("fast_tool", {"x": 21}, task=True) + result = await task.result() + assert result.data == 42 elapsed = time.monotonic() - t0 diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml index 9e3ed9dde..46b2081de 100644 --- a/tests/conformance/expected-failures.yml +++ b/tests/conformance/expected-failures.yml @@ -1,23 +1,6 @@ -# Scenarios the conformance suite runs that FastMCP does not pass. -# -# This is a baseline, not a to-do list: every entry needs a reason, and anything -# that is merely unimplemented in the *fixture* belongs in server.py instead. -# The suite is run with `--suite all`, so draft and pending scenarios count too. - server: - # Resource subscriptions (resources/subscribe, resources/unsubscribe) are not - # implemented. The server correctly advertises `resources.subscribe: false`, - # but the suite calls the methods regardless of the declared capability. Both - # scenarios were removed in MCP 2026-07-28, the version FastMCP targets, so - # this affects handshake-era clients only. + - completion-complete + - server-sse-polling - resources-subscribe - resources-unsubscribe - - # SEP-2663 MRTR-to-tasks composition: a task-supporting guard tool is - # expected to gather its input over foreground multi-round-trip rounds and - # only mint the task on the final round. FastMCP instead creates the task up - # front and parks it at `input_required`, answered through `tasks/update` — - # the model the `tasks-mrtr-input` scenario exercises. Supporting both would - # need the tool to declare which one it wants, which is an unmade API - # decision rather than a bug. - - tasks-mrtr-composition + - dns-rebinding-protection diff --git a/tests/conformance/server.py b/tests/conformance/server.py index cc8a020ae..158b0949e 100644 --- a/tests/conformance/server.py +++ b/tests/conformance/server.py @@ -9,33 +9,17 @@ import base64 import json import sys from enum import Enum as PyEnum -from typing import Annotated import mcp_types -import uvicorn -from mcp.shared.exceptions import MCPError -from mcp_types import ( - ClientCapabilities, - Completion, - EmbeddedResource, - ImageContent, - MissingRequiredClientCapabilityErrorData, - PromptReference, - TextContent, -) -from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY +from mcp_types import EmbeddedResource, ImageContent, TextContent from pydantic import BaseModel, Field from fastmcp import FastMCP from fastmcp.exceptions import ToolError from fastmcp.prompts import Message -from fastmcp.server.completions import CompletionValues from fastmcp.server.context import Context -from fastmcp.server.event_store import EventStore from fastmcp.tools.function_tool import FunctionTool -from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import Audio, Image -from fastmcp_tasks import TasksExtension # Minimal 1x1 red PNG for image tests (89 bytes) _1X1_PNG = base64.b64decode( @@ -63,29 +47,6 @@ _SILENT_WAV = ( server = FastMCP("conformance-test-server", dereference_schemas=False) -def require_client_capability(ctx: Context, capability: str) -> None: - """Raise `-32021` unless the client declared *capability* on this request. - - SEP-2575 makes capability negotiation per-request: the client repeats its - capabilities in each request's `_meta`, and a server that needs one the - client did not declare must answer with a - `MissingRequiredClientCapabilityError` whose `data.requiredCapabilities` is - a `ClientCapabilities` object keyed by the missing capability. - """ - client_params = ctx.session.client_params - declared = client_params.capabilities if client_params else None - if declared is not None and getattr(declared, capability, None) is not None: - return - data = MissingRequiredClientCapabilityErrorData( - required_capabilities=ClientCapabilities.model_validate({capability: {}}) - ) - raise MCPError( - code=MISSING_REQUIRED_CLIENT_CAPABILITY, - message=f"Client did not declare the required {capability!r} capability", - data=data.model_dump(by_alias=True, mode="json", exclude_none=True), - ) - - # --------------------------------------------------------------------------- # Tools # --------------------------------------------------------------------------- @@ -155,9 +116,9 @@ async def test_error_handling() -> str: async def test_tool_with_logging(ctx: Context) -> str: """Sends log notifications during execution.""" await ctx.info("Tool execution started") - await asyncio.sleep(0.01) + await asyncio.sleep(0.05) await ctx.info("Tool processing data") - await asyncio.sleep(0.01) + await asyncio.sleep(0.05) await ctx.info("Tool execution completed") return "Logging test complete." @@ -166,36 +127,21 @@ async def test_tool_with_logging(ctx: Context) -> str: async def test_tool_with_progress(ctx: Context) -> str: """Reports progress notifications.""" await ctx.report_progress(0, 100) - await asyncio.sleep(0.01) + await asyncio.sleep(0.05) await ctx.report_progress(50, 100) - await asyncio.sleep(0.01) + await asyncio.sleep(0.05) await ctx.report_progress(100, 100) return "Progress test complete." @server.tool(name="test_sampling") async def test_sampling(prompt: str, ctx: Context) -> str: - """Requests LLM sampling via the client. - - `Context` has no `sample()` — server-initiated sampling is not part of - FastMCP's server API. The handshake-era wire path is still supported and - still shipped (the proxy relay uses it), so this fixture reaches the SDK - session directly to keep the scenario covered. - """ - result = await ctx.session.create_message( # ty: ignore[deprecated] - messages=[ - mcp_types.SamplingMessage( - role="user", - content=mcp_types.TextContent(type="text", text=prompt), - ) - ], - max_tokens=512, - related_request_id=ctx.origin_request_id, + """Requests LLM sampling via the client.""" + result = await ctx.sample( + messages=[prompt], + result_type=str, ) - text = ( - result.content.text if isinstance(result.content, mcp_types.TextContent) else "" - ) - return f"Sampling result: {text}" + return f"Sampling result: {result}" class _UserInfo(BaseModel): @@ -315,7 +261,6 @@ server.add_tool( "type": "object", "$defs": { "address": { - "$anchor": "address", "type": "object", "properties": { "street": {"type": "string"}, @@ -327,423 +272,12 @@ server.add_tool( "name": {"type": "string"}, "address": {"$ref": "#/$defs/address"}, }, - # SEP-2106 requires servers to pass composition and conditional - # keywords through to the client untouched. - "allOf": [ - { - "anyOf": [ - {"required": ["name"]}, - {"required": ["address"]}, - ] - } - ], - "if": {"required": ["address"]}, - "then": {"properties": {"name": {"minLength": 1}}}, - "else": {}, "additionalProperties": False, }, ) ) -@server.tool(name="test_reconnection") -async def test_reconnection(ctx: Context) -> str: - """Closes the POST stream mid-call so the client must resume (SEP-1699). - - The result is written after the stream is gone, so it can only reach the - client through the event store on reconnect. - """ - await ctx.report_progress(0, 100) - await ctx.close_sse_stream() - await asyncio.sleep(0.1) - return "Reconnection test complete." - - -@server.tool(name="test_custom_headers") -async def test_custom_headers( - message: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Message"})], -) -> str: - """Mirrors an argument into an `Mcp-Param-Message` header (SEP-2243). - - The annotation is what makes the header recognized; the transport compares - the header against this argument before the tool ever runs. - """ - return f"Received message: {message}" - - -@server.tool(name="test_missing_capability") -async def test_missing_capability(ctx: Context) -> str: - """Requires the client to have declared the sampling capability (SEP-2575). - - A stateless server may not rely on a capability the client did not declare - in this request's `io.modelcontextprotocol/clientCapabilities` `_meta` - block, so an undeclared caller gets `-32021` rather than a tool result. - """ - require_client_capability(ctx, "sampling") - return "Client declared the sampling capability." - - -# --------------------------------------------------------------------------- -# Multi-round-trip input requests (SEP-2322) -# -# A guard component returns an `InputRequiredResult` naming what it needs; the -# client fulfils those requests and calls again, and the answers arrive on -# `ctx.input_responses` with any `ctx.request_state` echoed back. The framework -# seals and verifies `request_state`, so a tampered echo is rejected before a -# handler sees it. -# --------------------------------------------------------------------------- - - -def _elicit_request(message: str, field: str) -> mcp_types.ElicitRequest: - """A single-field form elicitation for *field*.""" - return mcp_types.ElicitRequest( - method="elicitation/create", - params=mcp_types.ElicitRequestFormParams( - message=message, - requested_schema={ - "type": "object", - "properties": {field: {"type": "string"}}, - "required": [field], - }, - ), - ) - - -def _sampling_request(text: str, max_tokens: int) -> mcp_types.CreateMessageRequest: - """A one-message sampling request.""" - return mcp_types.CreateMessageRequest( - method="sampling/createMessage", - params=mcp_types.CreateMessageRequestParams( - messages=[ - mcp_types.SamplingMessage( - role="user", - content=TextContent(type="text", text=text), - ) - ], - max_tokens=max_tokens, - ), - ) - - -def _elicited_field(responses: mcp_types.InputResponses, key: str, field: str) -> str: - """The accepted value of *field* from the elicitation answered under *key*.""" - answer = responses[key] - if not isinstance(answer, mcp_types.ElicitResult) or answer.content is None: - return "" - return str(answer.content.get(field, "")) - - -@server.tool(name="test_input_required_result_elicitation") -async def test_input_required_result_elicitation( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """Asks the client one elicitation question, then greets the answer. - - A retry whose `inputResponses` omit the key is re-asked rather than - errored: the answer is still missing, so the honest result is the same - request again. - """ - responses = ctx.input_responses - if responses is None or "user_name" not in responses: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={"user_name": _elicit_request("What is your name?", "name")}, - ) - return f"Hello, {_elicited_field(responses, 'user_name', 'name')}!" - - -@server.tool(name="test_input_required_result_sampling") -async def test_input_required_result_sampling( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """Asks the client to sample an answer, then echoes the sampled text.""" - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "capital_question": _sampling_request( - "What is the capital of France?", 100 - ) - }, - ) - answer = responses["capital_question"] - text = "" - if isinstance(answer, mcp_types.CreateMessageResult) and isinstance( - answer.content, TextContent - ): - text = answer.content.text - return f"Sampling result: {text}" - - -@server.tool(name="test_input_required_result_list_roots") -async def test_input_required_result_list_roots( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """Asks the client for its roots, then reports them back.""" - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "client_roots": mcp_types.ListRootsRequest(method="roots/list") - }, - ) - answer = responses["client_roots"] - roots = ( - [str(root.uri) for root in answer.roots] - if isinstance(answer, mcp_types.ListRootsResult) - else [] - ) - return f"Client roots: {', '.join(roots)}" - - -@server.tool(name="test_input_required_result_request_state") -async def test_input_required_result_request_state( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """Carries opaque state across the round trip and confirms it came back.""" - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "confirm": mcp_types.ElicitRequest( - method="elicitation/create", - params=mcp_types.ElicitRequestFormParams( - message="Please confirm", - requested_schema={ - "type": "object", - "properties": {"ok": {"type": "boolean"}}, - "required": ["ok"], - }, - ), - ) - }, - request_state="conformance-state-v1", - ) - if ctx.request_state != "conformance-state-v1": - raise ToolError("requestState was not echoed back intact") - return "state-ok: requestState round-tripped" - - -@server.tool(name="test_input_required_result_multiple_inputs") -async def test_input_required_result_multiple_inputs( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """Asks for elicitation, sampling, and roots in a single round.""" - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "user_name": _elicit_request("What is your name?", "name"), - "greeting": _sampling_request("Generate a greeting", 50), - "client_roots": mcp_types.ListRootsRequest(method="roots/list"), - }, - request_state="conformance-multi-v1", - ) - name = _elicited_field(responses, "user_name", "name") - return f"Collected {len(responses)} responses for {name}" - - -@server.tool(name="test_input_required_result_multi_round") -async def test_input_required_result_multi_round( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """Asks two dependent questions across three rounds.""" - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "step1": _elicit_request("Step 1: What is your name?", "name") - }, - request_state="round-1", - ) - if "step1" in responses: - name = _elicited_field(responses, "step1", "name") - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "step2": _elicit_request( - "Step 2: What is your favorite color?", "color" - ) - }, - request_state=f"round-2:{name}", - ) - color = _elicited_field(responses, "step2", "color") - name = (ctx.request_state or "round-2:").split(":", 1)[1] - return f"{name} likes {color}" - - -@server.tool(name="test_input_required_result_tampered_state") -async def test_input_required_result_tampered_state( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """Round-trips sealed state so a tampered echo is rejected by the framework.""" - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "confirm": _elicit_request("Please confirm", "confirmation") - }, - request_state="sealed-state-v1", - ) - return f"Accepted state: {ctx.request_state}" - - -@server.tool(name="test_input_required_result_capabilities") -async def test_input_required_result_capabilities( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """Asks only for the input methods this client declared it can answer.""" - responses = ctx.input_responses - if responses is not None: - return f"Collected {len(responses)} responses" - - client_params = ctx.session.client_params - declared = client_params.capabilities if client_params else None - requests: dict[str, mcp_types.InputRequest] = {} - if declared is not None and declared.sampling is not None: - requests["capital_question"] = _sampling_request( - "What is the capital of France?", 100 - ) - if declared is not None and declared.elicitation is not None: - requests["user_name"] = _elicit_request("What is your name?", "name") - if declared is not None and declared.roots is not None: - requests["client_roots"] = mcp_types.ListRootsRequest(method="roots/list") - if not requests: - return "Client declared no input capabilities" - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests=requests, - ) - - -# --------------------------------------------------------------------------- -# Background tasks (SEP-2663) -# -# The tasks extension is what turns `task=`-declared tools into background -# work; registering it also advertises `io.modelcontextprotocol/tasks` under -# `capabilities.extensions` and gates the `tasks/*` methods on negotiation. -# The in-memory Docket backend keeps the fixture to a single process. -# --------------------------------------------------------------------------- - -server.add_extension(TasksExtension(url="memory://")) - - -@server.tool(name="greet") -async def greet(name: str) -> str: - """A sync-only tool: never runs as a task.""" - return f"Hello, {name}!" - - -@server.tool(name="slow_compute", task=True) -async def slow_compute(seconds: float = 1.0, label: str = "") -> str: - """Sleeps for *seconds*, so a cancel can land while it is still running.""" - await asyncio.sleep(seconds) - return f"Computed {label} after {seconds} seconds" - - -@server.tool(name="failing_job", task=TaskConfig(mode="required")) -async def failing_job() -> str: - """Reports a tool execution error: `completed` with `result.isError`. - - Registered `required` so a client that never negotiated the extension gets - `-32021` rather than a synchronous run. - """ - await asyncio.sleep(1) - raise ToolError("This job intentionally fails for testing") - - -@server.tool(name="protocol_error_job", task=True) -async def protocol_error_job() -> str: - """Raises a protocol-level error: `failed` with an inlined `error`.""" - raise MCPError( - code=mcp_types.INTERNAL_ERROR, - message="Protocol-level failure for testing", - ) - - -@server.tool(name="confirm_delete", task=True) -async def confirm_delete( - filename: str, ctx: Context -) -> str | mcp_types.InputRequiredResult: - """Parks the task on one elicitation before doing the (pretend) deletion.""" - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "confirm": _elicit_request( - f"Confirm deletion of {filename}?", "confirmation" - ) - }, - ) - answer = _elicited_field(responses, "confirm", "confirmation") - return f"Deleted {filename}: {answer}" - - -@server.tool(name="multi_input", task=True) -async def multi_input(ctx: Context) -> str | mcp_types.InputRequiredResult: - """Parks the task on two elicitations at once, so they can be answered separately.""" - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "first": _elicit_request("First question?", "first"), - "second": _elicit_request("Second question?", "second"), - }, - ) - first = _elicited_field(responses, "first", "first") - second = _elicited_field(responses, "second", "second") - return f"Answers: {first}, {second}" - - -@server.tool(name="test_tool_with_task", task=TaskConfig(mode="required")) -async def test_tool_with_task(ctx: Context) -> str | mcp_types.InputRequiredResult: - """Gathers input over MRTR, then escalates the final round to a task. - - The composition is the point: round 1 is a plain `InputRequiredResult` - with no `taskId`, and the round that actually does the work becomes a - `CreateTaskResult` because the tool requires task execution. - """ - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={"user_name": _elicit_request("What is your name?", "name")}, - ) - return f"Task completed for {_elicited_field(responses, 'user_name', 'name')}" - - -# --------------------------------------------------------------------------- -# Completions -# --------------------------------------------------------------------------- - -_PROMPT_ARG_COMPLETIONS = ["paris", "park", "party"] - - -@server.completion -async def complete( - ref: mcp_types.PromptReference | mcp_types.ResourceTemplateReference, - argument: mcp_types.CompletionArgument, - context: mcp_types.CompletionContext | None, -) -> CompletionValues: - """Suggests values for `test_prompt_with_arguments` arguments.""" - if isinstance(ref, PromptReference) and ref.name == "test_prompt_with_arguments": - matches = [ - value - for value in _PROMPT_ARG_COMPLETIONS - if value.startswith(argument.value) - ] - return Completion(values=matches, total=len(matches), has_more=False) - return None - - # --------------------------------------------------------------------------- # Resources # --------------------------------------------------------------------------- @@ -838,49 +372,6 @@ async def test_prompt_with_image() -> list: ] -@server.prompt(name="test_input_required_result_prompt") -async def test_input_required_result_prompt( - ctx: Context, -) -> str | mcp_types.InputRequiredResult: - """A prompt that gathers its context by elicitation before rendering. - - `InputRequiredResult` is universal — it is a result type, not a tools/call - feature — so `prompts/get` can ask for input the same way a tool does. - """ - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={ - "user_context": _elicit_request( - "What context should the prompt use?", "context" - ) - }, - ) - context_value = _elicited_field(responses, "user_context", "context") - return f"Prompt rendered with context: {context_value}" - - -MCP_PATH = "/mcp" - - -def build_app(): - """The ASGI app the conformance suite is run against. - - Shared by the pytest fixture and the `__main__` entry point so both exercise - the same configuration. The event store is what makes SSE resumption work, - which `test_reconnection` depends on; host/origin protection is a spec MUST - for a localhost server without TLS or auth. - """ - return server.http_app( - transport="streamable-http", - path=MCP_PATH, - host_origin_protection=True, - event_store=EventStore(), - retry_interval=100, - ) - - if __name__ == "__main__": port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000 - uvicorn.run(build_app(), host="127.0.0.1", port=port, log_level="warning") + server.run(transport="streamable-http", host="127.0.0.1", port=port) diff --git a/tests/conformance/test_conformance.py b/tests/conformance/test_conformance.py index 5c17e8eb6..971efbfa4 100644 --- a/tests/conformance/test_conformance.py +++ b/tests/conformance/test_conformance.py @@ -1,14 +1,5 @@ """Run the MCP conformance test suite against a FastMCP server. -The suite is pinned rather than tracking `@latest`: upstream adds scenarios for -draft SEPs, so an unpinned run turns CI red on somebody else's release rather -than on a change of ours. Bumping `CONFORMANCE_VERSION` is how new scenarios -arrive, and the diff shows what they cost. - -`--suite all` includes draft and pending scenarios, which is deliberate — most -of what FastMCP implements ahead of a spec release lives there. Anything that -does not pass is listed in `expected-failures.yml` with a reason. - Requires Node.js and npx to be available on PATH. Mark: pytest -m conformance """ @@ -26,9 +17,7 @@ import uvicorn CONFORMANCE_DIR = Path(__file__).parent EXPECTED_FAILURES = CONFORMANCE_DIR / "expected-failures.yml" HOST = "127.0.0.1" - -#: Pinned version of `@modelcontextprotocol/conformance`. Bump deliberately. -CONFORMANCE_VERSION = "0.2.0-alpha.10" +MCP_PATH = "/mcp" def _get_free_port() -> int: @@ -47,10 +36,12 @@ def _require_npx(): @pytest.fixture(scope="module") def conformance_server(_require_npx): """Start the conformance test server in a background thread.""" - from tests.conformance.server import MCP_PATH, build_app + from tests.conformance.server import server as mcp_server port = _get_free_port() - config = uvicorn.Config(build_app(), host=HOST, port=port, log_level="warning") + app = mcp_server.http_app(transport="streamable-http", path=MCP_PATH) + + config = uvicorn.Config(app, host=HOST, port=port, log_level="warning") uv_server = uvicorn.Server(config) thread = threading.Thread(target=uv_server.run, daemon=True) @@ -64,7 +55,7 @@ def conformance_server(_require_npx): with socket.create_connection((HOST, port), timeout=1): break except OSError: - time.sleep(0.01) + time.sleep(0.1) else: pytest.fail("Conformance server did not start in time") @@ -75,13 +66,13 @@ def conformance_server(_require_npx): @pytest.mark.conformance -@pytest.mark.timeout(180) +@pytest.mark.timeout(120) def test_mcp_conformance(conformance_server): """Run the full MCP conformance test suite against the server.""" cmd = [ "npx", "--yes", - f"@modelcontextprotocol/conformance@{CONFORMANCE_VERSION}", + "@modelcontextprotocol/conformance@latest", "server", "--url", conformance_server, @@ -92,7 +83,7 @@ def test_mcp_conformance(conformance_server): if EXPECTED_FAILURES.exists(): cmd.extend(["--expected-failures", str(EXPECTED_FAILURES)]) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=150) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=90) # Print output for visibility in test results if result.stdout: diff --git a/tests/conftest.py b/tests/conftest.py index 8e2fb96c7..6eef890e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,17 +4,16 @@ import secrets import socket import sys from collections.abc import Callable, Generator +from datetime import timedelta from pathlib import Path from typing import Any import pytest -from mcp_types import SERVER_INFO_META_KEY from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from fastmcp.server.auth.providers.jwt import RSAKeyPair from fastmcp.utilities.tests import temporary_settings from tests.utilities.httpx2_mock import httpx_mock as httpx_mock @@ -24,21 +23,6 @@ if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -def user_meta(meta: dict[str, Any] | None) -> dict[str, Any] | None: - """Strip the SDK's `serverInfo` stamp from a result's `_meta`. - - Every 2026-era result carries `io.modelcontextprotocol/serverInfo` (spec - #3002), stamped by the SDK runner rather than by the component that - produced the result. Tests asserting on the meta a tool or resource set - itself use this to ignore the stamp, and get `None` back when the stamp was - the only entry. - """ - if meta is None: - return None - remaining = {k: v for k, v in meta.items() if k != SERVER_INFO_META_KEY} - return remaining or None - - def make_server_request_context( *, method: str = "tools/list", @@ -100,44 +84,24 @@ def enable_fastmcp_logger_propagation(caplog): root_logger.propagate = original_propagate -@pytest.fixture(scope="session") -def _settings_home_root(tmp_path_factory: pytest.TempPathFactory) -> Path: - """Session-scoped (i.e. per xdist-worker) base directory for isolated - settings.home directories. - - Created once via ``tmp_path_factory`` so ``isolate_settings_home`` can - carve out a per-test subdirectory with a plain, cheap ``mkdir`` instead - of requesting a fresh ``tmp_path`` (which every test would otherwise pay - for, autouse) on every single test. - """ - return tmp_path_factory.mktemp("fastmcp-test-home") - - @pytest.fixture(autouse=True) -def isolate_settings_home(_settings_home_root: Path): +def isolate_settings_home(tmp_path: Path): """Ensure each test uses an isolated settings.home directory. This prevents file locking issues when multiple tests share the same - storage directory in settings.home / "oauth-proxy". That collision is - not hypothetical: most oauth-proxy tests construct their proxy with the - same hardcoded jwt_signing_key ("test-secret"), and the storage - directory's name is a fingerprint derived from that key -- so any two - tests reusing it resolve to the *same* subdirectory. Reusing a single - settings.home across the whole session/worker would let one test's - persisted client/token state leak into the next, even though the tests - run sequentially within a worker. A fresh subdirectory per test avoids - that leakage while a session-scoped root avoids paying tmp_path's - per-test overhead (numbering, test-id sanitization, retention-policy - bookkeeping) for the ~99% of tests that never touch this directory. + storage directory in settings.home / "oauth-proxy". - Docket settings moved to the fastmcp-tasks package, so they are no longer - overridden here. + Also sets a fast Docket polling interval for tests — the default 50ms + is fine for production but still adds ~25ms average pickup latency per + task. 10ms makes task tests near-instant. """ - test_home = _settings_home_root / secrets.token_hex(8) - test_home.mkdir() + test_home = tmp_path / "fastmcp-test-home" + test_home.mkdir(exist_ok=True) with temporary_settings( home=test_home, + docket__minimum_check_interval=timedelta(milliseconds=10), + docket__url=f"memory://{secrets.token_hex(4)}", client_disconnect_timeout=1, ): yield @@ -147,31 +111,6 @@ def get_fn_name(fn: Callable[..., Any]) -> str: return fn.__name__ # ty: ignore[unresolved-attribute] -@pytest.fixture(scope="session") -def rsa_key_pair() -> RSAKeyPair: - """A shared RSA key pair for tests that just need *some* valid key material. - - RSA key generation costs tens of milliseconds; hundreds of auth tests - generating a fresh key per test adds up to real wall time for no benefit, - since almost none of them care that the key is unique. Tests that must - prove verification fails against a *different* key should use - ``rsa_key_pair_2`` instead of calling ``RSAKeyPair.generate()`` directly. - Tests that specifically exercise key generation or rotation should still - call ``RSAKeyPair.generate()`` themselves. - """ - return RSAKeyPair.generate() - - -@pytest.fixture(scope="session") -def rsa_key_pair_2() -> RSAKeyPair: - """A second shared RSA key pair, distinct from ``rsa_key_pair``. - - For tests that sign a token with the "wrong" key to prove verification - against ``rsa_key_pair`` fails. - """ - return RSAKeyPair.generate() - - @pytest.fixture def worker_id(request): """Get the xdist worker ID, or 'master' if not using xdist.""" diff --git a/tests/contrib/test_component_manager.py b/tests/contrib/test_component_manager.py index b31b0d0ee..8acae9aec 100644 --- a/tests/contrib/test_component_manager.py +++ b/tests/contrib/test_component_manager.py @@ -177,11 +177,10 @@ class TestComponentManagementRoutes: class TestAuthComponentManagementRoutes: """Test the component management routes with authentication for tools, resources, and prompts.""" - @pytest.fixture(autouse=True) - def setup(self, rsa_key_pair: RSAKeyPair): + def setup_method(self): """Set up test fixtures.""" - # Create an auth provider from the shared test key pair - key_pair = rsa_key_pair + # Generate a key pair and create an auth provider + key_pair = RSAKeyPair.generate() self.auth = JWTVerifier( public_key=key_pair.public_key, issuer="https://dev.example.com", @@ -464,10 +463,9 @@ class TestComponentManagerWithPath: class TestComponentManagerWithPathAuth: """Test component manager routes with auth when mounted at a custom path.""" - @pytest.fixture(autouse=True) - def setup(self, rsa_key_pair: RSAKeyPair): - # Create an auth provider from the shared test key pair - key_pair = rsa_key_pair + def setup_method(self): + # Generate a key pair and create an auth provider + key_pair = RSAKeyPair.generate() self.auth = JWTVerifier( public_key=key_pair.public_key, issuer="https://dev.example.com", diff --git a/tests/deprecated/test_elicitation.py b/tests/deprecated/test_elicitation.py new file mode 100644 index 000000000..d86e549d8 --- /dev/null +++ b/tests/deprecated/test_elicitation.py @@ -0,0 +1,29 @@ +"""Tests for deprecated elicitation behavior.""" + +from typing import Any, cast + +import pytest + +from fastmcp import Context, FastMCP +from fastmcp.client.client import Client +from fastmcp.client.elicitation import ElicitResult +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.elicitation import AcceptedElicitation + + +async def test_elicitation_none_response_type_warns_deprecation(): + """Passing response_type=None is deprecated — warn at call time.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(context: Context) -> dict[str, Any]: + with pytest.warns(FastMCPDeprecationWarning, match="response_type"): + result = await context.elicit(message="", response_type=None) + assert isinstance(result, AcceptedElicitation) + return cast(dict[str, Any], result.data) + + async def elicitation_handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={}) + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + await client.call_tool("my_tool", {}) diff --git a/tests/docs/test_doc_examples.py b/tests/docs/test_doc_examples.py index a95a23dec..3d51e4e2b 100644 --- a/tests/docs/test_doc_examples.py +++ b/tests/docs/test_doc_examples.py @@ -121,11 +121,10 @@ def test_doc_examples_quality(): syntax_failures.append(err) continue - # Frozen version snapshots (docs/v2/, docs/v3/) document older FastMCP - # releases, so their imports are validated against a package that no - # longer ships them. Syntax-check them above, but skip live-import - # validation. - if Path(ex.path).relative_to(DOCS_DIR).parts[0] in ("v2", "v3"): + # Frozen version snapshots (docs/v2/...) document older FastMCP releases, + # so their imports are validated against a package that no longer ships + # them. Syntax-check them above, but skip live-import validation. + if Path(ex.path).relative_to(DOCS_DIR).parts[0] == "v2": continue import_failures.extend(_check_fastmcp_imports(ex)) diff --git a/tests/docs/test_upgrade_guide_api_claims.py b/tests/docs/test_upgrade_guide_api_claims.py deleted file mode 100644 index c580a4380..000000000 --- a/tests/docs/test_upgrade_guide_api_claims.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Check the API claims the upgrade guides make against the real APIs. - -The other two doc tests cover code blocks: one executes them, one compares the -before/after pair. Neither looks at *prose*, and prose is where a migration -guide does most of its work — mapping tables, prompt checklists, and sentences -naming an attribute to use. Those claims went wrong repeatedly and in the same -way: an API was named without anyone checking it resolved. - -So this file checks the claims mechanically: - -- every ``ctx.<name>`` the guides tell a reader to *use* exists on the class - they'd be using it on, and every one they name as removed really is gone -- every ``MCPServer`` constructor parameter appears somewhere in the SDK v2 - guide, so a newly added SDK argument can't quietly go unmapped -- the ``request_context`` attributes the guides route people to are real - -Run: - uv run pytest tests/docs/test_upgrade_guide_api_claims.py -v -""" - -from __future__ import annotations - -import inspect -import re -import warnings -from pathlib import Path -from typing import Any - -import pytest - -UPGRADE_DIR = Path("docs/getting-started/upgrading") - - -def _guide(name: str) -> str: - return (UPGRADE_DIR / name).read_text("utf-8") - - -with warnings.catch_warnings(): - warnings.simplefilter("ignore") - from mcp.server.mcpserver import MCPServer - - from fastmcp import Context as FastMCPContext - - -# Context attributes the guides may mention without them existing on FastMCP's -# Context, because the guide's whole point is that they are gone or moved. Each -# is asserted to genuinely be absent, so a name that later gains an -# implementation stops being listed as missing. -DOCUMENTED_AS_ABSENT = { - "sample", - "sample_step", - "list_roots", - "mcp_server", - "headers", - "protocol_version", - "client_capabilities", - "elicit_url", - "close_standalone_sse_stream", - "notify_tools_changed", - "notify_resources_changed", - "notify_prompts_changed", - "notify_resource_updated", - "params", - "meta", -} - - -def test_absent_context_attributes_are_really_absent(): - """Names the guides describe as gone must not exist on FastMCP's Context. - - If one of these gains an implementation, the guides are now telling people - to work around something that works, and this test says so. - """ - resurrected = [ - n for n in sorted(DOCUMENTED_AS_ABSENT) if hasattr(FastMCPContext, n) - ] - assert not resurrected, ( - f"guides describe these as absent from fastmcp.Context, but they exist: {resurrected}" - ) - - -@pytest.mark.parametrize( - "guide", - sorted(p.name for p in UPGRADE_DIR.glob("*.mdx")), -) -def test_ctx_attributes_named_in_guides_exist(guide: str): - """Every ``ctx.<name>`` in a guide either exists or is documented as absent.""" - referenced = set(re.findall(r"`ctx\.([a-z_]+)", _guide(guide))) - unknown = { - name - for name in referenced - if not hasattr(FastMCPContext, name) and name not in DOCUMENTED_AS_ABSENT - } - assert not unknown, ( - f"{guide} names ctx.{{{', '.join(sorted(unknown))}}}, which do not exist on " - f"fastmcp.Context and are not in DOCUMENTED_AS_ABSENT" - ) - - -def test_request_context_attributes_the_guides_route_to_exist(): - """The guides send people to ``ctx.request_context`` for several attributes. - - ``FastMCPRequestContext`` resolves its attributes dynamically, so this is - checked against a live request rather than the class. - """ - import asyncio - - from fastmcp import Client, FastMCP - - mcp = FastMCP("probe") - - @mcp.tool - async def probe(ctx: FastMCPContext) -> list[str]: - rc = ctx.request_context - return [n for n in ("request_id", "meta", "protocol_version") if hasattr(rc, n)] - - async def run() -> list[str]: - async with Client(mcp) as client: - return (await client.call_tool("probe", {})).data - - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - present = asyncio.run(run()) - - assert set(present) == {"request_id", "meta", "protocol_version"} - - -# Context methods the SDK v2 guide says are *genuinely* unchanged. Existence is -# not enough for that claim — a method present on both classes with a different -# signature is worse than a missing one, because the import swap compiles and -# fails at runtime. So these are compared signature-for-signature. -CLAIMED_SIGNATURE_COMPATIBLE = ["report_progress"] - -# Present on both, but with signatures that differ. The guide must describe each -# migration rather than list it as carrying over; this pins the difference so a -# future SDK or FastMCP release that converges them shows up as a failure. -KNOWN_SIGNATURE_DIFFERENCES = ["log", "info", "debug", "warning", "error", "elicit"] - - -@pytest.mark.parametrize("method", CLAIMED_SIGNATURE_COMPATIBLE) -def test_methods_claimed_unchanged_have_identical_signatures(method: str): - from mcp.server.mcpserver import Context as SDKContext - - sdk = inspect.signature(getattr(SDKContext, method)) - fastmcp = inspect.signature(getattr(FastMCPContext, method)) - assert str(sdk) == str(fastmcp), ( - f"the SDK v2 guide lists ctx.{method} as carrying over unchanged, but " - f"the signatures differ:\n SDK : {sdk}\n FastMCP: {fastmcp}" - ) - - -@pytest.mark.parametrize("method", KNOWN_SIGNATURE_DIFFERENCES) -def test_methods_with_known_signature_differences_still_differ(method: str): - from mcp.server.mcpserver import Context as SDKContext - - sdk = inspect.signature(getattr(SDKContext, method)) - fastmcp = inspect.signature(getattr(FastMCPContext, method)) - assert str(sdk) != str(fastmcp), ( - f"ctx.{method} signatures now match; the guide's migration note for it " - f"is stale and should be moved to the unchanged list" - ) - - -# SDK v1's `mcp.server.fastmcp.FastMCP.__init__` parameters. Hardcoded because -# v1 cannot be installed alongside v4 to introspect — read from the published -# mcp 1.20.0 wheel. Anything here that FastMCP 4 does not accept must appear in -# the v1 guide, since a reader following "it's one import change" hits it. -SDK_V1_CONSTRUCTOR_PARAMS = [ - "name", "instructions", "website_url", "icons", "auth_server_provider", - "token_verifier", "event_store", "tools", "debug", "log_level", "host", - "port", "mount_path", "sse_path", "message_path", "streamable_http_path", - "json_response", "stateless_http", "warn_on_duplicate_resources", - "warn_on_duplicate_tools", "warn_on_duplicate_prompts", "dependencies", - "lifespan", "auth", "transport_security", "transport", -] # fmt: skip - - -def test_sdk_v1_constructor_params_fastmcp_rejects_are_documented(): - """Every v1 keyword FastMCP 4 refuses must be named in the v1 guide. - - The guide's headline is that upgrading is a single import change. That is - only honest if the constructor arguments it *doesn't* accept are spelled - out, so nobody follows the headline into a ``TypeError``. - """ - from fastmcp import FastMCP - - guide = _guide("from-mcp-sdk-v1.mdx") - probe: dict[str, Any] = { - "name": "s", - "icons": None, - "tools": None, - "lifespan": None, - } - - undocumented = [] - for param in SDK_V1_CONSTRUCTOR_PARAMS: - if param == "name": - continue - kwargs: dict[str, Any] = {param: probe.get(param)} - try: - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - FastMCP("s", **kwargs) - continue # accepted, nothing to document - except TypeError: - pass - except Exception: - continue # accepted the keyword, rejected the probe value - shorthand = param.replace("warn_on_duplicate", "") - if re.search(rf"`{re.escape(param)}[=`]", guide): - continue - if param.startswith("warn_on_duplicate") and re.search( - rf"`{re.escape(shorthand)}[=`]", guide - ): - continue - undocumented.append(param) - - assert not undocumented, ( - "SDK v1 FastMCP() parameters that FastMCP 4 rejects but from-mcp-sdk-v1.mdx " - f"never mentions: {undocumented}" - ) - - -def test_every_mcpserver_constructor_param_is_mapped(): - """The SDK v2 guide claims an exhaustive constructor mapping — hold it to that. - - A parameter added to ``MCPServer`` upstream should fail here rather than - reach a reader as an unmapped keyword that raises ``TypeError`` on FastMCP. - """ - guide = _guide("from-mcp-sdk-v2.mdx") - params = [ - p for p in inspect.signature(MCPServer.__init__).parameters if p != "self" - ] - - unmapped = [] - for param in params: - # `warn_on_duplicate_resources` is covered by the table's shorthand - # "warn_on_duplicate_tools, _resources, _prompts". - shorthand = param.replace("warn_on_duplicate", "") - if re.search(rf"`{re.escape(param)}[=`]", guide): - continue - if param.startswith("warn_on_duplicate") and re.search( - rf"`{re.escape(shorthand)}`", guide - ): - continue - unmapped.append(param) - - assert not unmapped, ( - f"MCPServer constructor parameters not mentioned in from-mcp-sdk-v2.mdx: {unmapped}" - ) diff --git a/tests/docs/test_upgrade_guide_equivalence.py b/tests/docs/test_upgrade_guide_equivalence.py deleted file mode 100644 index 0549ff228..000000000 --- a/tests/docs/test_upgrade_guide_equivalence.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Prove the SDK v2 upgrade guides produce an equivalent server. - -`test_upgrade_guide_examples.py` proves every example runs. That is necessary -but not sufficient: a migration guide is only correct if the "after" code -exposes the same MCP surface as the "before" code it replaces. A guide whose -halves both run but disagree on a tool's schema teaches a silent regression. - -So for each MCP SDK v2 guide, the complete before-and-after server pair is -lifted out of the page, both halves are built, and their advertised tools, -resources, templates, and prompts are compared. The SDK v1 guides are not -covered here — v1 is not installable alongside v4, so their "before" code -cannot be built to compare against. - -Run: - uv run pytest tests/docs/test_upgrade_guide_equivalence.py -v -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any -from uuid import uuid4 - -import pytest -from pytest_examples.find_examples import _extract_code_chunks - -from fastmcp import Client, FastMCP - -UPGRADE_DIR = Path("docs/getting-started/upgrading") - - -def _block_containing(page: str, needle: str) -> dict[str, Any]: - """Execute the one code block on `page` that contains `needle`.""" - path = UPGRADE_DIR / page - matches = [ - ex - for ex in _extract_code_chunks(path, path.read_text("utf-8"), uuid4()) - if needle in ex.source - ] - assert len(matches) == 1, ( - f"expected exactly one block in {page} containing {needle!r}, " - f"found {len(matches)}" - ) - namespace: dict[str, Any] = {"__name__": "fastmcp_docs_example"} - exec(compile(matches[0].source, str(path), "exec"), namespace) - return namespace - - -def _strip_titles(node: Any) -> Any: - """Recursively drop every "title" key, the one difference that's genuinely cosmetic. - - A hand-written SDK schema has no title anywhere; FastMCP derives one at every - level from the function/model it built the schema from. Everything else in the - tree — constraints, "additionalProperties", nested "anyOf"/"const", enum values — - is retained, because those describe what a client is allowed to send and a - silent difference there is exactly the kind of regression this test exists to - catch. - """ - if isinstance(node, dict): - return {k: _strip_titles(v) for k, v in node.items() if k != "title"} - if isinstance(node, list): - return [_strip_titles(v) for v in node] - return node - - -def _normalize(schema: dict[str, Any] | None) -> dict[str, Any]: - """Compare schemas by their full structure, modulo generated titles. - - "required" is sorted because the SDK and FastMCP may build it in a different - parameter order for the same signature — an ordering difference, not a - contract difference. - """ - if not schema: - return {} - stripped = _strip_titles(schema) - if "required" in stripped: - stripped["required"] = sorted(stripped["required"]) - return stripped - - -def _split_declared_strictness( - before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]] -) -> dict[str, dict[str, Any]]: - """Pop `"additionalProperties": false` from every migrated schema, asserting it's there. - - FastMCP's generated tool schemas declare `"additionalProperties": false`; a - schema built by either SDK server API does not declare it. This is a real - contract change, not a cosmetic one — both SDK APIs *accept* an unexpected - argument at call time, and FastMCP rejects it (pinned by - `test_fastmcp_tightens_the_argument_contract` in each class below). It is - popped here only so the rest of the schema can be compared field for field, - and popping is an assertion rather than a silent discard: if FastMCP ever - stops declaring it, or the SDK starts, this fails. - """ - stripped: dict[str, dict[str, Any]] = {} - for name, schema in after.items(): - schema = dict(schema) - assert schema.pop("additionalProperties", None) is False, ( - f"expected FastMCP to declare additionalProperties: false for {name!r}" - ) - assert "additionalProperties" not in before.get(name, {}), ( - f"expected the SDK schema for {name!r} not to declare additionalProperties" - ) - stripped[name] = schema - return stripped - - -async def _fastmcp_surface(mcp: FastMCP) -> dict[str, Any]: - async with Client(mcp) as client: - tools = await client.list_tools() - resources = await client.list_resources() - templates = await client.list_resource_templates() - prompts = await client.list_prompts() - return { - "tools": {t.name: _normalize(t.input_schema) for t in tools}, - "resources": {str(r.uri) for r in resources}, - "templates": {t.uri_template for t in templates}, - "prompts": {p.name: sorted(a.name for a in p.arguments or []) for p in prompts}, - } - - -class TestMCPServerGuide: - """docs/.../from-mcp-sdk-v2.mdx — the high-level MCPServer migration.""" - - @pytest.fixture(scope="class") - def pair(self) -> tuple[Any, FastMCP]: - before = _block_containing("from-mcp-sdk-v2.mdx", 'MCPServer("demo")') - after = _block_containing("from-mcp-sdk-v2.mdx", 'FastMCP("demo")') - return before["server"], after["mcp"] - - async def test_same_surface(self, pair): - server, mcp = pair - - before = { - "tools": { - t.name: _normalize(t.input_schema) for t in await server.list_tools() - }, - "resources": {str(r.uri) for r in await server.list_resources()}, - "templates": { - t.uri_template for t in await server.list_resource_templates() - }, - "prompts": { - p.name: sorted(a.name for a in p.arguments or []) - for p in await server.list_prompts() - }, - } - - after = await _fastmcp_surface(mcp) - after["tools"] = _split_declared_strictness(before["tools"], after["tools"]) - assert before == after - - async def test_fastmcp_tightens_the_argument_contract(self, pair): - """FastMCP rejects an unexpected argument where MCPServer accepts it. - - This is the behavior behind the `additionalProperties` schema difference, - and it is a real change for any caller that was passing extra keys. - """ - server, mcp = pair - - tolerated = await server.call_tool( - "greet", {"name": "World", "extra": "surprise"} - ) - assert tolerated.is_error is False - assert tolerated.content[0].text == "Hello, World!" - - async with Client(mcp) as client: - with pytest.raises(Exception): - await client.call_tool("greet", {"name": "World", "extra": "surprise"}) - - async def test_migrated_tools_still_work(self, pair): - _, mcp = pair - async with Client(mcp) as client: - greeting = await client.call_tool("greet", {"name": "World"}) - processed = await client.call_tool("process", {"items": ["a", "b"]}) - - assert greeting.data == "Hello, World!" - assert processed.data == "Processed 2 items" - - -class TestLowLevelGuide: - """docs/.../from-low-level-sdk-v2.mdx — the low-level Server migration.""" - - @pytest.fixture(scope="class") - def pair(self) -> tuple[dict[str, Any], FastMCP]: - before = _block_containing("from-low-level-sdk-v2.mdx", ' "demo",') - after = _block_containing("from-low-level-sdk-v2.mdx", 'FastMCP("demo")') - return before, after["mcp"] - - async def test_same_surface(self, pair): - handlers, mcp = pair - - tools = await handlers["list_tools"](None, None) - resources = await handlers["list_resources"](None, None) - prompts = await handlers["list_prompts"](None, None) - before = { - "tools": {t.name: _normalize(t.input_schema) for t in tools.tools}, - "resources": {str(r.uri) for r in resources.resources}, - "templates": set(), - "prompts": { - p.name: sorted(a.name for a in p.arguments or []) - for p in prompts.prompts - }, - } - - after = await _fastmcp_surface(mcp) - after["tools"] = _split_declared_strictness(before["tools"], after["tools"]) - assert before == after - - async def test_fastmcp_tightens_the_argument_contract(self, pair): - """FastMCP rejects an unexpected argument where the handler ignored it. - - A low-level handler reads `params.arguments` as a plain dict and never - looks at keys it doesn't need, so extras pass through silently. The - migrated tool rejects them. Pinned rather than normalized away, because - it is a real change for any caller that was passing extra keys. - """ - handlers, mcp = pair - params = type( - "Params", (), {"name": "greet", "arguments": {"name": "World", "extra": 1}} - )() - - tolerated = await handlers["call_tool"](None, params) - assert tolerated.content[0].text == "Hello, World!" - - async with Client(mcp) as client: - with pytest.raises(Exception): - await client.call_tool("greet", {"name": "World", "extra": 1}) - - async def test_handlers_and_tools_agree(self, pair): - """The rewritten tool returns what the hand-written handler returned.""" - handlers, mcp = pair - params = type("Params", (), {"name": "greet", "arguments": {"name": "World"}})() - - handler_result = await handlers["call_tool"](None, params) - async with Client(mcp) as client: - tool_result = await client.call_tool("greet", {"name": "World"}) - - assert handler_result.content[0].text == "Hello, World!" - assert tool_result.data == "Hello, World!" diff --git a/tests/docs/test_upgrade_guide_examples.py b/tests/docs/test_upgrade_guide_examples.py deleted file mode 100644 index 142c662b4..000000000 --- a/tests/docs/test_upgrade_guide_examples.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Execute the Python examples in the upgrade guides. - -`test_doc_examples.py` covers every page in `docs/`, but only checks that -examples parse and that their ``fastmcp.*`` imports resolve. The upgrade guides -carry a stronger obligation: someone lands on one mid-migration, copies a block, -and runs it. So these examples are actually executed, and their non-FastMCP -imports (`mcp`, `mcp_types`) are exercised along with everything else. - -Both halves of a `<CodeGroup>` are executed where they can be. The "after" code -is FastMCP 4, which this repo is. The "before" code is only runnable when it -targets the MCP SDK **v2** — the version installed here — which covers the two -SDK v2 guides. Blocks written against SDK v1 (whose `mcp.types` and -`mcp.server.fastmcp` no longer exist) and fragments that pair a "# Before" and -"# After" in one block are tagged ``test="skip"`` in the source and skipped here; -the count of those is pinned so a new one can't appear unnoticed. - -Run: - uv run pytest tests/docs/test_upgrade_guide_examples.py -v -""" - -from __future__ import annotations - -import warnings -from pathlib import Path -from uuid import uuid4 - -import pytest -from pytest_examples import CodeExample -from pytest_examples.find_examples import _extract_code_chunks - -import fastmcp - -UPGRADE_DIR = Path("docs/getting-started/upgrading") - -# Blocks deliberately not executable: SDK v1 API that is no longer installable, -# and before/after fragments that are not standalone programs. Pinned so that -# adding a skip is a visible decision rather than a silent one. -EXPECTED_SKIPS = 35 - - -def _examples() -> list[CodeExample]: - examples: list[CodeExample] = [] - for mdx_file in sorted(UPGRADE_DIR.rglob("*.mdx")): - code = mdx_file.read_text("utf-8") - examples.extend(_extract_code_chunks(mdx_file, code, uuid4())) - return examples - - -ALL = _examples() -RUNNABLE = [ex for ex in ALL if ex.prefix_settings().get("test") != "skip"] -SKIPPED = [ex for ex in ALL if ex.prefix_settings().get("test") == "skip"] - - -def _example_id(example: CodeExample) -> str: - return f"{Path(example.path).name}:{example.start_line}" - - -def test_guides_have_examples(): - """Guard against the extractor silently matching nothing.""" - assert len(RUNNABLE) >= 20, f"only found {len(RUNNABLE)} runnable examples" - - -def test_skip_count_is_pinned(): - """A newly unrunnable example should be a deliberate choice.""" - listing = "\n".join(f" {_example_id(ex)}" for ex in SKIPPED) - assert len(SKIPPED) == EXPECTED_SKIPS, ( - f"expected {EXPECTED_SKIPS} skipped examples, found {len(SKIPPED)}:\n{listing}" - ) - - -@pytest.fixture(autouse=True) -def restore_global_settings(): - """Undo any global setting an example changes. - - Some examples exist precisely to show a global toggle — the upgrade guide - demonstrates turning the camelCase bridge off with - ``fastmcp.settings.mcp_camelcase_compat = False``. Executing that here - would otherwise leave the bridge off for every test that runs afterwards in - the same process, which silently breaks unrelated suites. - """ - before = fastmcp.settings.model_dump() - yield - for field, value in before.items(): - if getattr(fastmcp.settings, field, value) != value: - setattr(fastmcp.settings, field, value) - - -@pytest.mark.parametrize("example", RUNNABLE, ids=[_example_id(e) for e in RUNNABLE]) -def test_example_executes(example: CodeExample): - """Every non-skipped example runs top to bottom without raising. - - Examples are executed under a module name other than ``__main__`` so an - ``if __name__ == "__main__": mcp.run()`` footer defines the server without - starting it. - """ - namespace: dict[str, object] = {"__name__": "fastmcp_docs_example"} - with warnings.catch_warnings(): - # Guides intentionally demonstrate deprecated surfaces (the camelCase - # bridge, SDK logging) whose warnings are the point being made. - warnings.simplefilter("ignore") - exec(compile(example.source, str(example.path), "exec"), namespace) diff --git a/tests/experimental/transforms/test_code_mode.py b/tests/experimental/transforms/test_code_mode.py index cea34de50..557f33c8b 100644 --- a/tests/experimental/transforms/test_code_mode.py +++ b/tests/experimental/transforms/test_code_mode.py @@ -791,63 +791,6 @@ async def test_code_mode_monty_execute_chaining() -> None: assert _unwrap_result(result) == {"result": 13} -@requires_monty -@pytest.mark.parametrize( - ("failing_call", "expected_message"), - [ - ("await call_tool('no_such_tool', {})", "Unknown tool: no_such_tool"), - ("await call_tool('boom', {})", "deliberate tool failure"), - ], - ids=["unknown-tool", "tool-error"], -) -async def test_code_mode_monty_call_tool_errors_are_catchable( - failing_call: str, expected_message: str -) -> None: - """Sandbox code can catch call_tool errors and preserve prior work.""" - mcp = FastMCP("CodeMode Monty Catch Errors") - - @mcp.tool - def add(x: int, y: int) -> int: - return x + y - - @mcp.tool - def boom() -> None: - raise ToolError("deliberate tool failure") - - mcp.add_transform(CodeMode(sandbox_provider=MontySandboxProvider())) - - code = ( - "total = (await call_tool('add', {'x': 2, 'y': 3}))['result']\n" - "caught = None\n" - "try:\n" - f" {failing_call}\n" - "except Exception as exc:\n" - " caught = str(exc)\n" - "return {'caught': caught, 'total': total}" - ) - result = await _run_tool(mcp, "execute", {"code": code}) - - assert _unwrap_result(result) == { - "caught": expected_message, - "total": 5, - } - - -@requires_monty -async def test_code_mode_monty_uncaught_call_tool_error_surfaces() -> None: - """Uncaught backend errors still propagate out of the sandbox.""" - mcp = FastMCP("CodeMode Monty Uncaught Error") - - @mcp.tool - def boom() -> None: - raise ToolError("deliberate tool failure") - - mcp.add_transform(CodeMode(sandbox_provider=MontySandboxProvider())) - - with pytest.raises(ToolError, match="deliberate tool failure"): - await _run_tool(mcp, "execute", {"code": "return await call_tool('boom', {})"}) - - @requires_monty async def test_code_mode_monty_bare_call_returns_empty() -> None: """Pins the reported #4263 symptom as a usage error, not a sandbox bug. diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md deleted file mode 100644 index 2d01abd08..000000000 --- a/tests/fixtures/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Vendored test fixtures - -## ext-tasks-schema-draft.json - -The draft JSON Schema for the `io.modelcontextprotocol/tasks` extension (SEP-2663), -vendored so `fastmcp-tasks` wire models are validated against the real upstream schema. - -- Source: https://github.com/modelcontextprotocol/ext-tasks — `schema/draft/schema.json` -- Vendored from commit `2c1425d9a288b9b1f489430fe1e00bb392b47e48` on 2026-07-21 -- Re-vendor with: - `curl -sfL https://raw.githubusercontent.com/modelcontextprotocol/ext-tasks/main/schema/draft/schema.json -o tests/fixtures/ext-tasks-schema-draft.json` - -The upstream schema is a draft and may change; when re-vendoring, update the commit -hash above and re-run the schema-validation tests to surface any drift. diff --git a/tests/fixtures/ext-tasks-schema-draft.json b/tests/fixtures/ext-tasks-schema-draft.json deleted file mode 100644 index d6ccaff7e..000000000 --- a/tests/fixtures/ext-tasks-schema-draft.json +++ /dev/null @@ -1,1834 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://modelcontextprotocol.io/ext-tasks/schema.json", - "title": "MCP Tasks Extension", - "description": "JSON Schema for MCP Tasks extension protocol messages. Extension Identifier: io.modelcontextprotocol/tasks", - "$defs": { - "CancelTaskRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "const": "2.0" - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - ] - }, - "method": { - "type": "string", - "const": "tasks/cancel" - }, - "params": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ], - "additionalProperties": false - } - }, - "required": [ - "jsonrpc", - "id", - "method", - "params" - ], - "additionalProperties": false - }, - "CancelTaskResult": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "_meta": { - "type": "object", - "properties": { - "progressToken": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - ] - }, - "io.modelcontextprotocol/related-task": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ], - "additionalProperties": false - } - }, - "additionalProperties": {} - } - }, - "additionalProperties": {} - }, - "CancelledTask": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "cancelled" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - }, - "CompletedTask": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "completed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "result": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "result" - ], - "additionalProperties": false - }, - "CreateTaskResult": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "allOf": [ - { - "type": "object", - "properties": { - "_meta": { - "type": "object", - "properties": { - "progressToken": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - ] - }, - "io.modelcontextprotocol/related-task": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ], - "additionalProperties": false - } - }, - "additionalProperties": {} - } - }, - "additionalProperties": {} - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "type": "string", - "const": "working" - }, - { - "type": "string", - "const": "input_required" - }, - { - "type": "string", - "const": "completed" - }, - { - "type": "string", - "const": "failed" - }, - { - "type": "string", - "const": "cancelled" - } - ] - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - } - ] - }, - "DetailedTask": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "anyOf": [ - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "working" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "input_required" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "inputRequests": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - {}, - {}, - {} - ] - } - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "inputRequests" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "completed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "result": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "result" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "failed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "error": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "cancelled" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - } - ] - }, - "FailedTask": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "failed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "error": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "error" - ], - "additionalProperties": false - }, - "GetTaskRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "const": "2.0" - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - ] - }, - "method": { - "type": "string", - "const": "tasks/get" - }, - "params": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ], - "additionalProperties": false - } - }, - "required": [ - "jsonrpc", - "id", - "method", - "params" - ], - "additionalProperties": false - }, - "GetTaskResult": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "allOf": [ - { - "type": "object", - "properties": { - "_meta": { - "type": "object", - "properties": { - "progressToken": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - ] - }, - "io.modelcontextprotocol/related-task": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ], - "additionalProperties": false - } - }, - "additionalProperties": {} - } - }, - "additionalProperties": {} - }, - { - "anyOf": [ - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "working" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "input_required" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "inputRequests": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - {}, - {}, - {} - ] - } - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "inputRequests" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "completed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "result": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "result" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "failed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "error": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "cancelled" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - } - ] - } - ] - }, - "InputRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "anyOf": [ - {}, - {}, - {} - ] - }, - "InputRequests": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - {}, - {}, - {} - ] - } - }, - "InputRequiredTask": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "input_required" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "inputRequests": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - {}, - {}, - {} - ] - } - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "inputRequests" - ], - "additionalProperties": false - }, - "InputResponse": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "anyOf": [ - {}, - {}, - {} - ] - }, - "InputResponses": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - {}, - {}, - {} - ] - } - }, - "Task": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "anyOf": [ - { - "type": "string", - "const": "working" - }, - { - "type": "string", - "const": "input_required" - }, - { - "type": "string", - "const": "completed" - }, - { - "type": "string", - "const": "failed" - }, - { - "type": "string", - "const": "cancelled" - } - ] - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - }, - "TaskStatusNotificationParams": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "allOf": [ - { - "type": "object", - "properties": { - "_meta": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "additionalProperties": {} - }, - { - "anyOf": [ - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "working" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "input_required" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "inputRequests": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - {}, - {}, - {} - ] - } - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "inputRequests" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "completed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "result": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "result" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "failed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "error": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "cancelled" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - } - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - ] - }, - "TaskStatusNotification": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "const": "2.0" - }, - "method": { - "type": "string", - "const": "notifications/tasks" - }, - "params": { - "allOf": [ - { - "type": "object", - "properties": { - "_meta": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "additionalProperties": {} - }, - { - "anyOf": [ - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "working" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "input_required" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "inputRequests": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - {}, - {}, - {} - ] - } - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "inputRequests" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "completed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "result": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "result" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "failed" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - }, - "error": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "cancelled" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - } - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - ] - } - }, - "required": [ - "jsonrpc", - "method", - "params" - ], - "additionalProperties": false - }, - "TaskStatus": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "anyOf": [ - { - "type": "string", - "const": "working" - }, - { - "type": "string", - "const": "input_required" - }, - { - "type": "string", - "const": "completed" - }, - { - "type": "string", - "const": "failed" - }, - { - "type": "string", - "const": "cancelled" - } - ] - }, - "TaskSubscriptionAcknowledgedNotifications": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "taskIds": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "TaskSubscriptionNotifications": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "taskIds": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "TasksExtensionCapability": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "not": {} - } - }, - "UpdateTaskRequest": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "jsonrpc": { - "type": "string", - "const": "2.0" - }, - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - ] - }, - "method": { - "type": "string", - "const": "tasks/update" - }, - "params": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "inputResponses": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - {}, - {}, - {} - ] - } - } - }, - "required": [ - "taskId", - "inputResponses" - ], - "additionalProperties": false - } - }, - "required": [ - "jsonrpc", - "id", - "method", - "params" - ], - "additionalProperties": false - }, - "UpdateTaskResult": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "_meta": { - "type": "object", - "properties": { - "progressToken": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - ] - }, - "io.modelcontextprotocol/related-task": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - } - }, - "required": [ - "taskId" - ], - "additionalProperties": false - } - }, - "additionalProperties": {} - } - }, - "additionalProperties": {} - }, - "WorkingTask": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "status": { - "type": "string", - "const": "working" - }, - "statusMessage": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "lastUpdatedAt": { - "type": "string" - }, - "ttlMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "pollIntervalMs": { - "type": "number" - } - }, - "required": [ - "taskId", - "status", - "createdAt", - "lastUpdatedAt", - "ttlMs" - ], - "additionalProperties": false - } - } -} diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py index 0079072f5..af7863746 100644 --- a/tests/integration_tests/auth/test_github_provider_integration.py +++ b/tests/integration_tests/auth/test_github_provider_integration.py @@ -359,10 +359,8 @@ async def test_github_oauth_with_mock(github_client_with_mock: Client): """Test complete GitHub OAuth flow with mocked callback.""" async with github_client_with_mock: - # Reaching the server at all requires successful OAuth. `list_tools` stands - # in for `ping` here because it works in either protocol era, and a default - # client negotiates the modern one, which has no `ping` method. - assert await github_client_with_mock.list_tools() + # Test that we can ping the server (requires successful OAuth) + assert await github_client_with_mock.ping() # Test that we can call protected tools result = await github_client_with_mock.call_tool("get_protected_data", {}) diff --git a/tests/integration_tests/test_github_mcp_remote.py b/tests/integration_tests/test_github_mcp_remote.py index 9ec9776a3..b6190afc7 100644 --- a/tests/integration_tests/test_github_mcp_remote.py +++ b/tests/integration_tests/test_github_mcp_remote.py @@ -24,13 +24,6 @@ pytestmark = pytest.mark.xfail( @pytest.fixture(name="streamable_http_client") def fixture_streamable_http_client() -> Client[StreamableHttpTransport]: - """A default client, so this suite exercises `mode="auto"` against a real peer. - - GitHub answers `server/discover` but has not adopted result tagging, so its - result envelope is not conformant with the modern version it advertises. The - client's conformance check catches that at connect time and degrades to the - initialize handshake, which is why these tests behave as they always have. - """ assert FASTMCP_GITHUB_TOKEN is not None return Client( @@ -41,20 +34,6 @@ def fixture_streamable_http_client() -> Client[StreamableHttpTransport]: ) -@pytest.fixture(name="legacy_client") -def fixture_legacy_client() -> Client[StreamableHttpTransport]: - """A handshake-pinned client, for capabilities that exist only in that era.""" - assert FASTMCP_GITHUB_TOKEN is not None - - return Client( - StreamableHttpTransport( - url=GITHUB_REMOTE_MCP_URL, - auth=BearerAuth(FASTMCP_GITHUB_TOKEN), - ), - mode="legacy", - ) - - class TestGithubMCPRemote: async def test_connect_disconnect( self, @@ -65,16 +44,11 @@ class TestGithubMCPRemote: await streamable_http_client._disconnect() # pylint: disable=W0212 (protected-access) assert streamable_http_client.is_connected() is False - async def test_ping(self, legacy_client: Client[StreamableHttpTransport]): - """Test pinging the server. - - `ping` is defined only in the handshake era — the modern protocol version - does not carry the method at all — so this pins `mode="legacy"` rather - than relying on the default negotiation landing there. - """ - async with legacy_client: - assert legacy_client.is_connected() is True - result = await legacy_client.ping() + async def test_ping(self, streamable_http_client: Client[StreamableHttpTransport]): + """Test pinging the server.""" + async with streamable_http_client: + assert streamable_http_client.is_connected() is True + result = await streamable_http_client.ping() assert result is True async def test_list_tools( @@ -132,10 +106,6 @@ class TestGithubMCPRemote: """Test calling a list_commit tool""" async with streamable_http_client: assert streamable_http_client.is_connected() - # On a modern connection the client derives `Mcp-Param-*` headers from - # the tool's schema, which it only holds once the tool has been listed - # in this session. Listing first keeps the call correct in either era. - await streamable_http_client.list_tools() result = await streamable_http_client.call_tool( "list_commits", {"owner": "prefecthq", "repo": "fastmcp"} ) diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py index d44668783..201632e01 100644 --- a/tests/prompts/test_prompt.py +++ b/tests/prompts/test_prompt.py @@ -1,9 +1,5 @@ -from pathlib import Path -from typing import Annotated, Any - import pytest from mcp_types import EmbeddedResource, TextResourceContents -from pydantic import Field from fastmcp.prompts.base import ( Message, @@ -317,75 +313,8 @@ class TestPromptTypeConversion: assert result.messages == [Message("Hello world (repeated 3 times)")] - @pytest.mark.parametrize( - ("annotation", "value"), - [ - (Annotated[str, Field(description="Text")], '"hello"'), - (str | None, "null"), - (Any, "123"), - (object, "true"), - (int | str, "42"), - ], - ) - async def test_string_compatible_annotations_preserve_wire_strings( - self, annotation: Any, value: str - ): - def typed_prompt(value): - return f"{type(value).__name__}:{value!r}" - - typed_prompt.__annotations__ = {"value": annotation, "return": str} - prompt = Prompt.from_function(typed_prompt) - - result = await prompt.render(arguments={"value": value}) - - assert result.messages == [Message(f"str:{value!r}")] - - async def test_optional_non_string_still_decodes_json_null(self): - def optional_integer_prompt(value: int | None) -> str: - return f"{type(value).__name__}:{value!r}" - - prompt = Prompt.from_function(optional_integer_prompt) - - result = await prompt.render(arguments={"value": "null"}) - - assert result.messages == [Message("NoneType:None")] - - @pytest.mark.parametrize( - ("annotation", "value", "expected"), - [ - (bytes, '"hello"', b"hello"), - (Path, '"folder/file.txt"', Path("folder/file.txt")), - ], - ) - async def test_string_coercible_non_string_annotations_decode_json( - self, annotation: Any, value: str, expected: Any - ): - def typed_prompt(value): - return f"{type(value).__name__}:{value!r}" - - typed_prompt.__annotations__ = {"value": annotation, "return": str} - prompt = Prompt.from_function(typed_prompt) - - result = await prompt.render(arguments={"value": value}) - - assert result.messages == [Message(f"{type(expected).__name__}:{expected!r}")] - class TestPromptArgumentDescriptions: - def test_string_compatible_annotation_guidance_preserves_raw_strings(self): - def documented_prompt( - text: Annotated[str, Field(description="Text")], - ) -> str: - return text - - prompt = Prompt.from_function(documented_prompt) - - assert prompt.arguments is not None - text_arg = next(arg for arg in prompt.arguments if arg.name == "text") - assert text_arg.description is not None - assert "Provide as a JSON string" not in text_arg.description - assert "Encode non-string values as JSON." in text_arg.description - def test_enhanced_descriptions_for_non_string_types(self): """Test that non-string argument types get enhanced descriptions with JSON schema.""" @@ -414,7 +343,7 @@ class TestPromptArgumentDescriptions: assert numbers_arg is not None assert numbers_arg.description is not None assert ( - "Provide a value matching the following JSON schema:" + "Provide as a JSON string matching the following schema:" in numbers_arg.description ) assert '{"items":{"type":"integer"},"type":"array"}' in numbers_arg.description @@ -425,7 +354,7 @@ class TestPromptArgumentDescriptions: assert metadata_arg is not None assert metadata_arg.description is not None assert ( - "Provide a value matching the following JSON schema:" + "Provide as a JSON string matching the following schema:" in metadata_arg.description ) assert ( @@ -439,7 +368,7 @@ class TestPromptArgumentDescriptions: assert threshold_arg is not None assert threshold_arg.description is not None assert ( - "Provide a value matching the following JSON schema:" + "Provide as a JSON string matching the following schema:" in threshold_arg.description ) assert '{"type":"number"}' in threshold_arg.description @@ -450,7 +379,7 @@ class TestPromptArgumentDescriptions: assert active_arg is not None assert active_arg.description is not None assert ( - "Provide a value matching the following JSON schema:" + "Provide as a JSON string matching the following schema:" in active_arg.description ) assert '{"type":"boolean"}' in active_arg.description @@ -481,7 +410,7 @@ class TestPromptArgumentDescriptions: assert "A list of integers to process" in numbers_arg.description assert "\n\n" in numbers_arg.description # Should have newline separator assert ( - "Provide a value matching the following JSON schema:" + "Provide as a JSON string matching the following schema:" in numbers_arg.description ) @@ -498,7 +427,7 @@ class TestPromptArgumentDescriptions: # String parameters should not have schema enhancement if arg.description is not None: assert ( - "Provide a value matching the following JSON schema:" + "Provide as a JSON string matching the following schema:" not in arg.description ) diff --git a/tests/resources/test_resource_security.py b/tests/resources/test_resource_security.py index ef56c6529..f530d63c9 100644 --- a/tests/resources/test_resource_security.py +++ b/tests/resources/test_resource_security.py @@ -149,7 +149,6 @@ class TestBareSlimImport: The import must be deferred to the point of actual screening. """ - @pytest.mark.subprocess_heavy def test_resources_import_without_sdk(self): code = textwrap.dedent( """ diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 58960c4b7..d4238cd94 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -4,7 +4,7 @@ from urllib.parse import quote import pytest from pydantic import BaseModel -from fastmcp import Client, Context, FastMCP +from fastmcp import Context, FastMCP from fastmcp.resources import ResourceTemplate from fastmcp.resources.function_resource import FunctionResource from fastmcp.resources.template import ( @@ -1014,129 +1014,3 @@ class TestMatchExpandRoundTrip: """Expanding params and matching them back reproduces the original values.""" uri = expand_uri_template(template, params) assert match_uri_template(uri, template) == params - - -class TestTemplateMimeType: - """A template must serve the MIME type it advertises in listings.""" - - @pytest.mark.parametrize( - "declared,expected", - [ - ("text/csv", "text/csv"), - ("text/html", "text/html"), - (None, "text/plain"), - ], - ) - async def test_declared_mime_type_is_served_on_read( - self, declared: str | None, expected: str - ): - """A string return honors the template's declared mime_type.""" - mcp = FastMCP() - - kwargs = {"mime_type": declared} if declared else {} - - @mcp.resource("data://report/{name}", **kwargs) - def report(name: str) -> str: - return f"col_a,col_b\n{name},1" - - async with Client(mcp) as client: - result = await client.read_resource("data://report/x") - - assert result[0].mime_type == expected - - async def test_read_mime_type_matches_listed_mime_type(self): - """resources/templates/list and resources/read must agree.""" - mcp = FastMCP() - - @mcp.resource("data://item/{id}", mime_type="text/csv") - def item(id: str) -> str: - return f"id\n{id}" - - async with Client(mcp) as client: - listed = await client.list_resource_templates() - read = await client.read_resource("data://item/7") - - assert listed[0].mime_type == read[0].mime_type == "text/csv" - - async def test_json_native_return_matches_concrete_resource(self): - """A dict return behaves the same for templates and concrete resources.""" - mcp = FastMCP() - - @mcp.resource("data://concrete") - def concrete() -> dict: - return {"k": 1} - - @mcp.resource("data://templated/{id}") - def templated(id: str) -> dict: - return {"k": id} - - async with Client(mcp) as client: - concrete_result = await client.read_resource("data://concrete") - templated_result = await client.read_resource("data://templated/1") - - assert concrete_result[0].mime_type == templated_result[0].mime_type - - async def test_component_meta_propagates_to_content(self): - """Template-level meta reaches content items, as it does for resources.""" - mcp = FastMCP() - - @mcp.resource("data://tagged/{id}", meta={"team": "infra"}) - def tagged(id: str) -> str: - return id - - async with Client(mcp) as client: - result = await client.read_resource("data://tagged/9") - - assert result[0].meta is not None - assert result[0].meta["team"] == "infra" - - -class TestInternalMetaNotLeaked: - """FastMCP's private visibility marker must never reach the wire.""" - - @pytest.mark.parametrize( - "uri,read_uri", - [ - ("data://plain", "data://plain"), - ("data://tmpl/{id}", "data://tmpl/1"), - ], - ) - async def test_visibility_marker_stripped_from_content_meta( - self, uri: str, read_uri: str - ): - """Applying a visibility rule must not add internal meta to content.""" - mcp = FastMCP() - - if "{" in uri: - - @mcp.resource(uri) - def templated(id: str) -> str: - return id - else: - - @mcp.resource(uri) - def concrete() -> str: - return "value" - - # Applying any visibility rule stamps the internal marker on meta - mcp.enable(names={"templated" if "{" in uri else "concrete"}) - - async with Client(mcp) as client: - result = await client.read_resource(read_uri) - - assert result[0].meta is None - - async def test_user_meta_survives_stripping(self): - """Only the internal namespace is removed; user meta is preserved.""" - mcp = FastMCP() - - @mcp.resource("data://tagged/{id}", meta={"team": "infra"}) - def tagged(id: str) -> str: - return id - - mcp.enable(names={"tagged"}) - - async with Client(mcp) as client: - result = await client.read_resource("data://tagged/1") - - assert result[0].meta == {"team": "infra"} diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index bd5af842c..ad72b6168 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -5,7 +5,6 @@ from pydantic import AnyUrl, BaseModel from fastmcp import Client, FastMCP from fastmcp.resources import Resource, ResourceContent, ResourceResult from fastmcp.resources.function_resource import FunctionResource -from tests.conftest import user_meta class TestResourceValidation: @@ -324,7 +323,7 @@ class TestResourceMetaPropagation: async with Client(mcp) as client: result = await client.read_resource_mcp("test://with-meta") - assert user_meta(result.meta) == {"version": "2.0", "source": "test"} + assert result.meta == {"version": "2.0", "source": "test"} async def test_resource_content_meta_received_by_client(self): """Meta set on ResourceContent is received by MCP client.""" @@ -356,7 +355,7 @@ class TestResourceMetaPropagation: async with Client(mcp) as client: result = await client.read_resource_mcp("test://both-meta") - assert user_meta(result.meta) == {"result_key": "result_val"} + assert result.meta == {"result_key": "result_val"} assert result.contents[0].meta == {"item_key": "item_val"} async def test_json_native_return_preserves_component_meta(self): diff --git a/tests/server/auth/oauth_proxy/conftest.py b/tests/server/auth/oauth_proxy/conftest.py index f3a02edbc..4e402ad05 100644 --- a/tests/server/auth/oauth_proxy/conftest.py +++ b/tests/server/auth/oauth_proxy/conftest.py @@ -3,7 +3,6 @@ import asyncio import secrets import time -from contextlib import suppress from unittest.mock import Mock from urllib.parse import urlencode @@ -32,7 +31,6 @@ class MockOAuthProvider: self.base_url = f"http://localhost:{port}" self.app = None self.server = None - self._serve_task: asyncio.Task | None = None # Storage for OAuth state self.authorization_codes = {} @@ -237,25 +235,16 @@ class MockOAuthProvider: self.server = Server(config) # Start server in background - self._serve_task = asyncio.create_task(self.server.serve()) + asyncio.create_task(self.server.serve()) - # Wait for the server to finish startup instead of a fixed sleep. - # uvicorn.Server flips `started` to True once the listening socket - # is bound, right before it would start accepting connections. - deadline = asyncio.get_event_loop().time() + 5.0 - while not self.server.started: - if asyncio.get_event_loop().time() > deadline: - raise RuntimeError("Mock OAuth server failed to start in time") - await asyncio.sleep(0.005) + # Wait for server to be ready + await asyncio.sleep(0.05) async def stop(self): """Stop the mock OAuth server.""" if self.server: self.server.should_exit = True - if self._serve_task is not None: - # Wait for the actual shutdown rather than a fixed sleep. - with suppress(TimeoutError): - await asyncio.wait_for(self._serve_task, timeout=5.0) + await asyncio.sleep(0.01) def reset(self): """Reset all state for next test.""" diff --git a/tests/server/auth/oauth_proxy/test_authorization.py b/tests/server/auth/oauth_proxy/test_authorization.py index 7b67ae934..f3272094e 100644 --- a/tests/server/auth/oauth_proxy/test_authorization.py +++ b/tests/server/auth/oauth_proxy/test_authorization.py @@ -55,67 +55,6 @@ class TestOAuthProxyAuthorization: assert transaction.client_state == "client-state-123" assert transaction.scopes == ["read", "write"] - async def test_authorize_records_configured_scopes_when_client_omits_scope( - self, oauth_proxy - ): - """A client that omits `scope` has the configured default recorded. - - The transaction is the single source of truth for what is being - authorized, so it must hold the effective scopes rather than an empty - list that each downstream consumer patches up on its own. - """ - client = OAuthClientInformationFull.model_validate( - { - "client_id": "test-client", - "client_secret": "test-secret", - "redirect_uris": ["http://localhost:54321/callback"], - "jwt_signing_key": "test-secret", - } - ) - await oauth_proxy.register_client(client) - - params = AuthorizationParams( - redirect_uri=AnyUrl("http://localhost:54321/callback"), - redirect_uri_provided_explicitly=True, - state="client-state-123", - code_challenge="challenge-abc", - scopes=None, - ) - - redirect_url = await oauth_proxy.authorize(client, params) - txn_id = parse_qs(urlparse(redirect_url).query)["txn_id"][0] - - transaction = await oauth_proxy._transaction_store.get(key=txn_id) - assert transaction is not None - assert transaction.scopes == ["read", "write"] - - async def test_authorize_does_not_widen_explicit_client_scopes(self, oauth_proxy): - """An explicit narrow scope request is never widened to the configured set.""" - client = OAuthClientInformationFull.model_validate( - { - "client_id": "test-client", - "client_secret": "test-secret", - "redirect_uris": ["http://localhost:54321/callback"], - "jwt_signing_key": "test-secret", - } - ) - await oauth_proxy.register_client(client) - - params = AuthorizationParams( - redirect_uri=AnyUrl("http://localhost:54321/callback"), - redirect_uri_provided_explicitly=True, - state="client-state-123", - code_challenge="challenge-abc", - scopes=["read"], - ) - - redirect_url = await oauth_proxy.authorize(client, params) - txn_id = parse_qs(urlparse(redirect_url).query)["txn_id"][0] - - transaction = await oauth_proxy._transaction_store.get(key=txn_id) - assert transaction is not None - assert transaction.scopes == ["read"] - class TestOAuthProxyPKCE: """Tests for OAuth proxy PKCE forwarding.""" diff --git a/tests/server/auth/oauth_proxy/test_client_registration.py b/tests/server/auth/oauth_proxy/test_client_registration.py index f90211349..37c63ba0b 100644 --- a/tests/server/auth/oauth_proxy/test_client_registration.py +++ b/tests/server/auth/oauth_proxy/test_client_registration.py @@ -197,243 +197,6 @@ class TestOAuthProxyClientRegistration: assert registered_client is not None assert registered_client.scope == "read write calendar" - @pytest.mark.parametrize( - "requested_auth_method", - [None, "client_secret_post", "client_secret_basic"], - ) - async def test_dcr_response_is_public_client( - self, oauth_proxy, requested_auth_method - ): - """The DCR response must describe the public client the proxy actually - stores — never a confidential method / secret the proxy does not enforce - and does not advertise in server metadata. - """ - registration = {"redirect_uris": ["https://client.example.com/callback"]} - if requested_auth_method is not None: - registration["token_endpoint_auth_method"] = requested_auth_method - - app = Starlette(routes=oauth_proxy.get_routes()) - transport = httpx2.ASGITransport(app=app) - - async with httpx2.AsyncClient( - transport=transport, - base_url="https://myserver.com", - ) as client: - response = await client.post("/register", json=registration) - - assert response.status_code == 201 - client_info = response.json() - assert client_info["token_endpoint_auth_method"] == "none" - assert client_info.get("client_secret") is None - - -class TestApplicationTypeRegistration: - """SEP-837: DCR registration honors the client's application_type.""" - - async def test_default_application_type_is_native(self, oauth_proxy): - """Omitting application_type defaults to native (the SDK default), so a - loopback redirect URI registers successfully.""" - client_info = OAuthClientInformationFull( - client_id="default-client", - redirect_uris=[AnyUrl("http://localhost:12345/callback")], - ) - - await oauth_proxy.register_client(client_info) - - stored = await oauth_proxy.get_client("default-client") - assert stored is not None - assert stored.application_type == "native" - - async def test_native_loopback_range_registers_then_authorizes_new_port( - self, oauth_proxy - ): - """A native client on 127.0.0.2 keeps loopback port flexibility. - - Registration accepts the whole 127.0.0.0/8 range, and the stored client - must then authorize a different ephemeral port on that same address. - """ - client_info = OAuthClientInformationFull( - client_id="loopback-range-client", - redirect_uris=[AnyUrl("http://127.0.0.2:3000/callback")], - application_type="native", - ) - - await oauth_proxy.register_client(client_info) - - stored = await oauth_proxy.get_client("loopback-range-client") - assert stored is not None - - uri = stored.validate_redirect_uri(AnyUrl("http://127.0.0.2:54321/callback")) - assert str(uri) == "http://127.0.0.2:54321/callback" - - # A different host is still rejected — flexibility is loopback-only. - with pytest.raises(InvalidRedirectUriError): - stored.validate_redirect_uri( - AnyUrl("http://evil.example.com:54321/callback") - ) - - async def test_native_client_accepts_loopback(self, oauth_proxy): - client_info = OAuthClientInformationFull( - client_id="native-client", - redirect_uris=[AnyUrl("http://127.0.0.1:55555/callback")], - application_type="native", - ) - - await oauth_proxy.register_client(client_info) - - stored = await oauth_proxy.get_client("native-client") - assert stored is not None - assert stored.application_type == "native" - - async def test_web_client_accepts_https(self, oauth_proxy): - client_info = OAuthClientInformationFull( - client_id="web-client", - redirect_uris=[AnyUrl("https://client.example.com/callback")], - application_type="web", - ) - - await oauth_proxy.register_client(client_info) - - stored = await oauth_proxy.get_client("web-client") - assert stored is not None - assert stored.application_type == "web" - - async def test_web_client_rejects_loopback(self, oauth_proxy): - client_info = OAuthClientInformationFull( - client_id="web-loopback-client", - redirect_uris=[AnyUrl("http://localhost:12345/callback")], - application_type="web", - ) - - with pytest.raises(RegistrationError, match="application_type 'web'"): - await oauth_proxy.register_client(client_info) - - async def test_web_client_rejects_custom_scheme(self, oauth_proxy): - client_info = OAuthClientInformationFull( - client_id="web-custom-client", - redirect_uris=[AnyUrl("com.example.app:/oauth/callback")], - application_type="web", - ) - - with pytest.raises(RegistrationError, match="application_type 'web'"): - await oauth_proxy.register_client(client_info) - - async def test_web_client_without_redirect_uris_is_rejected(self, oauth_proxy): - """A web client with no redirect_uris could never authorize. - - Omitted redirect_uris fall back to the `http://localhost` placeholder, - which a web client can never use (loopback http fails its own rule), so - registering one would only create a client guaranteed to fail later. - """ - client_info = OAuthClientInformationFull( - client_id="web-no-uris", - redirect_uris=None, - application_type="web", - ) - - with pytest.raises(RegistrationError, match="required for application_type"): - await oauth_proxy.register_client(client_info) - - async def test_native_client_without_redirect_uris_still_allowed(self, oauth_proxy): - """Native clients may still defer redirect_uris to authorization time.""" - client_info = OAuthClientInformationFull( - client_id="native-no-uris", - redirect_uris=None, - application_type="native", - ) - - await oauth_proxy.register_client(client_info) - - stored = await oauth_proxy.get_client("native-no-uris") - assert stored is not None - - @pytest.mark.parametrize("application_type", ["web", "native"]) - async def test_unsafe_scheme_rejected_regardless_of_type( - self, oauth_proxy, application_type - ): - client_info = OAuthClientInformationFull( - client_id="unsafe-client", - redirect_uris=[AnyUrl("javascript:alert(document.cookie)//")], - application_type=application_type, - ) - - with pytest.raises(RegistrationError, match="invalid_redirect_uri"): - await oauth_proxy.register_client(client_info) - - -class TestApplicationTypeRegistrationOverHTTP: - """SEP-837: application_type is honored on the real POST /register route. - - The SDK's RegistrationHandler parses application_type but drops it before - calling register_client, so these tests exercise the actual ASGI route to - prove FastMCP recovers the value end to end (a direct register_client call - would not catch the SDK dropping the field).""" - - async def _register(self, oauth_proxy, payload: dict): - app = Starlette(routes=oauth_proxy.get_routes()) - transport = httpx2.ASGITransport(app=app) - async with httpx2.AsyncClient( - transport=transport, - base_url="https://myserver.com", - ) as client: - return await client.post("/register", json=payload) - - async def test_web_client_with_loopback_rejected_over_http(self, oauth_proxy): - response = await self._register( - oauth_proxy, - { - "redirect_uris": ["http://localhost:12345/callback"], - "application_type": "web", - }, - ) - - assert response.status_code == 400 - body = response.json() - assert body["error"] == "invalid_redirect_uri" - assert "application_type 'web'" in body["error_description"] - - async def test_web_client_with_https_accepted_over_http(self, oauth_proxy): - response = await self._register( - oauth_proxy, - { - "redirect_uris": ["https://client.example.com/callback"], - "application_type": "web", - }, - ) - - assert response.status_code == 201 - body = response.json() - assert body["application_type"] == "web" - - stored = await oauth_proxy.get_client(body["client_id"]) - assert stored is not None - assert stored.application_type == "web" - - async def test_native_client_with_loopback_accepted_over_http(self, oauth_proxy): - response = await self._register( - oauth_proxy, - { - "redirect_uris": ["http://localhost:12345/callback"], - "application_type": "native", - }, - ) - - assert response.status_code == 201 - body = response.json() - assert body["application_type"] == "native" - - async def test_default_application_type_is_native_over_http(self, oauth_proxy): - """Omitting application_type over HTTP defaults to native, so a loopback - redirect is accepted (preserving pre-SEP-837 behavior).""" - response = await self._register( - oauth_proxy, - {"redirect_uris": ["http://localhost:12345/callback"]}, - ) - - assert response.status_code == 201 - body = response.json() - assert body["application_type"] == "native" - class TestUpstreamClientIdFallback: """Tests for clients that skip DCR and use the upstream client_id directly.""" diff --git a/tests/server/auth/oauth_proxy/test_config.py b/tests/server/auth/oauth_proxy/test_config.py index cc9b2bff6..a69330a3e 100644 --- a/tests/server/auth/oauth_proxy/test_config.py +++ b/tests/server/auth/oauth_proxy/test_config.py @@ -6,15 +6,15 @@ from mcp.server.auth.provider import AuthorizationParams, AuthorizeError from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyHttpUrl, AnyUrl -from fastmcp.server.auth.identity_assertion import ( - normalize_resource_url, - server_url_has_query, -) from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_proxy.proxy import ( + _normalize_resource_url, + _server_url_has_query, +) class TestNormalizeResourceUrl: - """Unit tests for the normalize_resource_url helper function.""" + """Unit tests for the _normalize_resource_url helper function.""" @pytest.mark.parametrize( "url,expected", @@ -46,7 +46,7 @@ class TestNormalizeResourceUrl: ) def test_normalizes_urls_correctly(self, url: str, expected: str): """Test that URLs are normalized by stripping query params, fragments, and trailing slashes.""" - assert normalize_resource_url(url) == expected + assert _normalize_resource_url(url) == expected @pytest.mark.parametrize( "url,has_query", @@ -58,9 +58,9 @@ class TestNormalizeResourceUrl: ("https://example.com/mcp?a=1&b=2", True), ], ) - def testserver_url_has_query(self, url: str, has_query: bool): + def test_server_url_has_query(self, url: str, has_query: bool): """Test detection of query parameters in server URLs.""" - assert server_url_has_query(url) == has_query + assert _server_url_has_query(url) == has_query class TestResourceURLValidation: diff --git a/tests/server/auth/oauth_proxy/test_identity_assertion.py b/tests/server/auth/oauth_proxy/test_identity_assertion.py deleted file mode 100644 index bbf463d34..000000000 --- a/tests/server/auth/oauth_proxy/test_identity_assertion.py +++ /dev/null @@ -1,995 +0,0 @@ -"""Tests for server-side SEP-990 identity assertion (ID-JAG) support. - -These exercise the OAuthProxy token endpoint end-to-end using a locally-minted -fake IdP JWT (a keypair is generated per test). The proxy's JWKS lookup is -served via httpx_mock, so no real network calls are made. -""" - -import subprocess -import sys -import time - -import httpx2 -import pytest -from joserfc import jwk, jwt -from key_value.aio.stores.memory import MemoryStore -from mcp.shared.auth import OAuthClientInformationFull -from pydantic import AnyUrl - -from fastmcp import FastMCP -from fastmcp.server.auth import IdentityAssertion -from fastmcp.server.auth.identity_assertion import ( - ID_JAG_GRANT_PROFILE, - ID_JAG_TYP, - JWT_BEARER_GRANT_TYPE, -) -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient -from fastmcp.server.auth.providers.jwt import RSAKeyPair -from tests.server.auth.oauth_proxy.conftest import MockTokenVerifier -from tests.utilities.httpx2_mock import HTTPXMock - -BASE_URL = "https://myserver.com" -ISSUER = "https://login.acme-corp.com" -JWKS_URI = "https://login.acme-corp.com/jwks" -RESOURCE = f"{BASE_URL}/mcp" - - -def _b64url_json(value: object) -> str: - """Base64url-encode a JSON value as a JWT segment (no padding).""" - import base64 - import json - - raw = json.dumps(value).encode() - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() - - -def _idp_jwks(key_pair: RSAKeyPair) -> dict: - """Build a JWKS document from an RSA key pair's public key.""" - public_key = jwk.import_key(key_pair.public_key, "RSA") - data = public_key.as_dict() - data["kid"] = "idp-key-1" - data["alg"] = "RS256" - return {"keys": [data]} - - -def _mint_id_jag( - key_pair: RSAKeyPair, - *, - issuer: str = ISSUER, - audience: str = BASE_URL, - subject: str = "employee@acme-corp.com", - typ: str = ID_JAG_TYP, - jti: str = "jti-1", - expires_in: int = 120, - scope: str | None = None, - include_iat: bool = True, - nbf_offset: int | None = None, - client_id: str | None = "mcp-client", - resource: str | None = RESOURCE, - claim_overrides: dict | None = None, -) -> str: - """Mint a fake ID-JAG JWT with full control over header and claims. - - `client_id` and `resource` default to values matching the standard test - client and this server's resource URL — SEP-990 binds the assertion to - both, and the exchange enforces the bindings. Pass `None` to omit. - `claim_overrides` is merged in last, for injecting malformed values. - """ - now = int(time.time()) - header = {"alg": "RS256", "typ": typ, "kid": "idp-key-1"} - payload: dict = { - "iss": issuer, - "aud": audience, - "sub": subject, - "exp": now + expires_in, - "jti": jti, - } - if client_id is not None: - payload["client_id"] = client_id - if resource is not None: - payload["resource"] = resource - if include_iat: - payload["iat"] = now - if nbf_offset is not None: - payload["nbf"] = now + nbf_offset - if scope is not None: - payload["scope"] = scope - if claim_overrides: - payload.update(claim_overrides) - signing_key = jwk.import_key(key_pair.private_key.get_secret_value(), "RSA") - return jwt.encode(header, payload, signing_key, algorithms=["RS256"]) - - -def _make_proxy(identity_assertion: IdentityAssertion | None) -> OAuthProxy: - return OAuthProxy( - upstream_authorization_endpoint="https://login.acme-corp.com/authorize", - upstream_token_endpoint="https://login.acme-corp.com/token", - upstream_client_id="upstream-client", - upstream_client_secret="upstream-secret", - token_verifier=MockTokenVerifier(), - base_url=BASE_URL, - jwt_signing_key="test-signing-key", - client_storage=MemoryStore(), - identity_assertion=identity_assertion, - ) - - -@pytest.fixture -def idp_key(rsa_key_pair: RSAKeyPair) -> RSAKeyPair: - return rsa_key_pair - - -@pytest.fixture -def config() -> IdentityAssertion: - # Explicit jwks_uris avoids OIDC discovery so only the JWKS fetch is mocked. - return IdentityAssertion( - trusted_issuers=[ISSUER], - jwks_uris={ISSUER: JWKS_URI}, - ) - - -async def _register_client(proxy: OAuthProxy) -> None: - """Register the MCP client so the token endpoint can authenticate it.""" - await proxy.register_client( - OAuthClientInformationFull( - client_id="mcp-client", - client_secret="mcp-secret", - redirect_uris=[AnyUrl("http://localhost/callback")], - grant_types=[JWT_BEARER_GRANT_TYPE], - ) - ) - - -async def _post_token( - proxy: OAuthProxy, - assertion: str, - *, - request_scope: str | None = None, - resource: str | None = None, - register: bool = True, -) -> httpx2.Response: - """POST a jwt-bearer grant to the proxy's /token endpoint via an ASGI app. - - When ``register`` is True the standard test client is registered first; pass - ``register=False`` to exercise a client the caller has already stored. - """ - if register: - await _register_client(proxy) - app = FastMCP("ID-JAG Server", auth=proxy).http_app() - transport = httpx2.ASGITransport(app=app) - data = { - "grant_type": JWT_BEARER_GRANT_TYPE, - "assertion": assertion, - "client_id": "mcp-client", - "client_secret": "mcp-secret", - } - if request_scope is not None: - data["scope"] = request_scope - if resource is not None: - data["resource"] = resource - async with httpx2.AsyncClient(transport=transport, base_url=BASE_URL) as client: - return await client.post("/token", data=data) - - -class TestIdentityAssertionConfig: - def test_requires_trusted_issuers(self): - with pytest.raises(ValueError): - IdentityAssertion(trusted_issuers=[]) - - def test_rejects_blank_issuer(self): - with pytest.raises(ValueError): - IdentityAssertion(trusted_issuers=[" "]) - - @pytest.mark.parametrize("algorithm", ["ES256", "PS256", "RS384"]) - def test_accepts_asymmetric_algorithm(self, algorithm: str): - IdentityAssertion(trusted_issuers=[ISSUER], algorithm=algorithm) - - @pytest.mark.parametrize( - "algorithm", ["HS256", "EdDSA", "none", "", "RS999", "ES999"] - ) - def test_rejects_incompatible_or_unsupported_algorithm(self, algorithm: str): - # HS* has no JWKS equivalent (shared secret, not a public key); EdDSA, - # typo'd variants like RS999, and other unimportable algorithms would - # otherwise surface as a 500 on the first exchange rather than a clean - # config error now. The allowlist is exactly what JWTVerifier supports. - with pytest.raises(ValueError): - IdentityAssertion(trusted_issuers=[ISSUER], algorithm=algorithm) - - def test_defaults(self): - cfg = IdentityAssertion(trusted_issuers=[ISSUER]) - assert cfg.access_token_expiry_seconds == 300 - assert cfg.audience is None - assert cfg.jwks_uris is None - - def test_per_issuer_algorithms_validated(self): - IdentityAssertion(trusted_issuers=[ISSUER], algorithms={ISSUER: "ES256"}) - with pytest.raises(ValueError): - IdentityAssertion(trusted_issuers=[ISSUER], algorithms={ISSUER: "HS256"}) - - @pytest.mark.subprocess_heavy - def test_lazy_reexport_does_not_import_module(self): - # fastmcp.server.auth must not load identity_assertion (and its - # httpx2 dependency) eagerly — the re-export is lazy via __getattr__. - code = ( - "import sys\n" - "import fastmcp.server.auth\n" - "loaded = [m for m in sys.modules if 'identity_assertion' in m]\n" - "assert not loaded, f'eagerly loaded: {loaded}'\n" - "from fastmcp.server.auth import IdentityAssertion\n" - "print('OK')\n" - ) - result = subprocess.run( - [sys.executable, "-c", code], capture_output=True, text=True - ) - assert result.returncode == 0, result.stderr - assert "OK" in result.stdout - - -class TestMetadataAdvertisement: - async def _metadata(self, proxy: OAuthProxy) -> dict: - app = FastMCP("ID-JAG Server", auth=proxy).http_app() - transport = httpx2.ASGITransport(app=app) - async with httpx2.AsyncClient(transport=transport, base_url=BASE_URL) as client: - resp = await client.get("/.well-known/oauth-authorization-server") - return resp.json() - - async def test_advertises_grant_when_enabled(self, config: IdentityAssertion): - proxy = _make_proxy(config) - metadata = await self._metadata(proxy) - assert JWT_BEARER_GRANT_TYPE in metadata["grant_types_supported"] - assert ( - ID_JAG_GRANT_PROFILE in metadata["authorization_grant_profiles_supported"] - ) - - async def test_not_advertised_when_disabled(self): - proxy = _make_proxy(None) - metadata = await self._metadata(proxy) - assert JWT_BEARER_GRANT_TYPE not in metadata["grant_types_supported"] - assert metadata.get("authorization_grant_profiles_supported") is None - - async def test_advertises_none_auth_method_without_cimd( - self, config: IdentityAssertion - ): - # DCR clients are public (token_endpoint_auth_method="none"), so when - # the jwt-bearer grant is advertised, `none` must be advertised too — - # even with CIMD (which also adds it) disabled. - proxy = OAuthProxy( - upstream_authorization_endpoint="https://login.acme-corp.com/authorize", - upstream_token_endpoint="https://login.acme-corp.com/token", - upstream_client_id="upstream-client", - upstream_client_secret="upstream-secret", - token_verifier=MockTokenVerifier(), - base_url=BASE_URL, - jwt_signing_key="test-signing-key", - client_storage=MemoryStore(), - identity_assertion=config, - enable_cimd=False, - ) - metadata = await self._metadata(proxy) - assert JWT_BEARER_GRANT_TYPE in metadata["grant_types_supported"] - assert "none" in metadata["token_endpoint_auth_methods_supported"] - - -class TestTokenEndpoint: - async def test_happy_path_issues_token( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - httpx_mock: HTTPXMock, - ): - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, scope="read write") - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 200 - body = resp.json() - assert body["token_type"] == "Bearer" - assert body["access_token"] - # SEP-990: no refresh token is issued. - assert body.get("refresh_token") is None - assert body["expires_in"] == config.access_token_expiry_seconds - - async def test_issued_token_carries_subject( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - httpx_mock: HTTPXMock, - ): - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - proxy = _make_proxy(config) - proxy.set_mcp_path("/mcp") - assertion = _mint_id_jag(idp_key, subject="alice@acme-corp.com") - - resp = await _post_token(proxy, assertion) - access_token = resp.json()["access_token"] - - # The FastMCP-issued token validates via the proxy and exposes the subject. - loaded = await proxy.load_access_token(access_token) - assert loaded is not None - assert loaded.subject == "alice@acme-corp.com" - - async def test_asserted_subject_flows_into_auth_context( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - httpx_mock: HTTPXMock, - ): - """The issued token, verified via the same path the bearer-auth middleware - uses (`verify_token` -> `load_access_token`), exposes the asserted subject — - which is exactly what `get_access_token()` returns to a tool.""" - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - proxy = _make_proxy(config) - proxy.set_mcp_path("/mcp") - assertion = _mint_id_jag( - idp_key, subject="carol@acme-corp.com", scope="read write" - ) - - resp = await _post_token(proxy, assertion) - access_token = resp.json()["access_token"] - - verified = await proxy.verify_token(access_token) - assert verified is not None - assert verified.subject == "carol@acme-corp.com" - assert "read" in verified.scopes and "write" in verified.scopes - - async def test_grant_rejected_when_not_configured( - self, - idp_key: RSAKeyPair, - ): - proxy = _make_proxy(None) - assertion = _mint_id_jag(idp_key) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 400 - assert resp.json()["error"] == "unsupported_grant_type" - - async def test_request_scope_cannot_widen_assertion_scope( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - httpx_mock: HTTPXMock, - ): - """A client whose assertion grants only `readonly` cannot obtain `admin` - by asking for it at the token endpoint. The request `scope` is not covered - by the signed assertion, so it may only narrow the granted set.""" - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - proxy = _make_proxy(config) - proxy.set_mcp_path("/mcp") - assertion = _mint_id_jag(idp_key, scope="readonly") - - resp = await _post_token(proxy, assertion, request_scope="admin") - - assert resp.status_code == 200 - verified = await proxy.verify_token(resp.json()["access_token"]) - assert verified is not None - assert "admin" not in verified.scopes - - async def test_request_scope_narrows_assertion_scope( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - httpx_mock: HTTPXMock, - ): - """When the request `scope` is a subset of the assertion's granted scopes, - the issued token carries only the intersection.""" - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - proxy = _make_proxy(config) - proxy.set_mcp_path("/mcp") - assertion = _mint_id_jag(idp_key, scope="read write") - - resp = await _post_token(proxy, assertion, request_scope="read") - - assert resp.status_code == 200 - verified = await proxy.verify_token(resp.json()["access_token"]) - assert verified is not None - assert "read" in verified.scopes - assert "write" not in verified.scopes - - async def test_request_narrowing_preserves_required_scope( - self, - idp_key: RSAKeyPair, - httpx_mock: HTTPXMock, - ): - """A configured `required_scope` the assertion grants must always ride on - the issued token; the request `scope` may only narrow the optional - remainder. Requesting `read` must not drop the mandatory `admin`.""" - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - config = IdentityAssertion( - trusted_issuers=[ISSUER], - jwks_uris={ISSUER: JWKS_URI}, - required_scopes=["admin"], - ) - proxy = _make_proxy(config) - proxy.set_mcp_path("/mcp") - assertion = _mint_id_jag(idp_key, scope="admin read") - - resp = await _post_token(proxy, assertion, request_scope="read") - - assert resp.status_code == 200 - verified = await proxy.verify_token(resp.json()["access_token"]) - assert verified is not None - assert "admin" in verified.scopes - assert "read" in verified.scopes - - async def test_request_for_required_scope_only( - self, - idp_key: RSAKeyPair, - httpx_mock: HTTPXMock, - ): - """Requesting only the required scope keeps it and narrows away the rest.""" - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - config = IdentityAssertion( - trusted_issuers=[ISSUER], - jwks_uris={ISSUER: JWKS_URI}, - required_scopes=["admin"], - ) - proxy = _make_proxy(config) - proxy.set_mcp_path("/mcp") - assertion = _mint_id_jag(idp_key, scope="admin read") - - resp = await _post_token(proxy, assertion, request_scope="admin") - - assert resp.status_code == 200 - verified = await proxy.verify_token(resp.json()["access_token"]) - assert verified is not None - assert "admin" in verified.scopes - assert "read" not in verified.scopes - - -@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) -class TestValidationMatrix: - @pytest.fixture(autouse=True) - def _mock_jwks(self, idp_key: RSAKeyPair, httpx_mock: HTTPXMock): - # Optional: several matrix tests reject before any JWKS fetch happens. - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key), is_optional=True) - - async def test_untrusted_issuer_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, issuer="https://evil.example.com") - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_wrong_audience_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, audience="https://other-server.com") - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_wrong_typ_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, typ="JWT") - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_expired_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, expires_in=-10) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_replayed_jti_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, jti="replay-me") - - first = await _post_token(proxy, assertion) - second = await _post_token(proxy, assertion) - - assert first.status_code == 200 - assert second.status_code == 401 - assert second.json()["error"] == "invalid_grant" - - async def test_wrong_signature_rejected( - self, config: IdentityAssertion, rsa_key_pair_2: RSAKeyPair - ): - # Sign with a different key than the one served in the JWKS. - other_key = rsa_key_pair_2 - proxy = _make_proxy(config) - assertion = _mint_id_jag(other_key) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_missing_required_scope_rejected(self, idp_key: RSAKeyPair): - config = IdentityAssertion( - trusted_issuers=[ISSUER], - jwks_uris={ISSUER: JWKS_URI}, - required_scopes=["admin"], - ) - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, scope="read") - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_future_nbf_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # An ID-JAG whose not-before (`nbf`) claim is in the future is not yet - # valid and must be rejected. - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, nbf_offset=300) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_past_nbf_accepted( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # An `nbf` in the past means the assertion is already valid. - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, nbf_offset=-60) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 200 - assert resp.json()["access_token"] - - async def test_aud_matching_advertised_issuer_with_trailing_slash( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # Metadata advertises the issuer exactly as pydantic renders base_url — - # a bare domain gains a trailing slash. An IdP that sets `aud` to that - # advertised value verbatim must be accepted, not rejected on the slash. - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, audience=f"{BASE_URL}/") - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 200 - - @pytest.mark.parametrize("claim", ["exp", "iat", "nbf"]) - @pytest.mark.parametrize("bad_value", ["not-a-number", [], {}, True]) - async def test_non_numeric_temporal_claim_rejected( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - claim: str, - bad_value: object, - ): - # A validly-signed assertion could still carry a malformed exp/iat/nbf - # (a misbehaving IdP); comparing against it must map to invalid_grant, - # not an unhandled TypeError. `True`/`False` are excluded even though - # bool subclasses int in Python. - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, claim_overrides={claim: bad_value}) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - @pytest.mark.parametrize("bad_jti", [["a", "b"], {"x": 1}, 42]) - async def test_non_string_jti_rejected( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - bad_jti: object, - ): - # An array/object jti is unhashable — the cache lookup would raise - # TypeError (a 500) instead of a clean invalid_grant. - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key, claim_overrides={"jti": bad_jti}) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_non_object_payload_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # A JWT with a valid typ header but a JSON-array payload must map to a - # clean invalid_grant, not an unhandled 500 from calling `.get()` on a list. - header = _b64url_json({"alg": "RS256", "typ": ID_JAG_TYP, "kid": "idp-key-1"}) - payload = _b64url_json([]) - assertion = f"{header}.{payload}.signature" - - resp = await _post_token(proxy=_make_proxy(config), assertion=assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_non_object_header_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # Same class of bug as the payload case: a JSON-array JOSE header must - # map to invalid_grant, not a 500 from `.get()` on a list. - header = _b64url_json([]) - payload = _b64url_json({"iss": ISSUER, "sub": "x"}) - assertion = f"{header}.{payload}.signature" - - resp = await _post_token(proxy=_make_proxy(config), assertion=assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_assertion_for_other_client_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # SEP-990: the IdP signs which client the assertion was minted for. - # A registered client must not be able to redeem an assertion minted - # for a different client — with public clients, this signed binding is - # the control that stops cross-client redemption of leaked assertions. - assertion = _mint_id_jag(idp_key, client_id="some-other-client") - - resp = await _post_token(proxy=_make_proxy(config), assertion=assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_wrong_client_binding_does_not_consume_jti( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # An assertion presented by the wrong client must be rejected WITHOUT - # its jti being recorded as consumed -- otherwise the client it - # actually belongs to would find that same jti already "replayed" - # when it (or a retry) presents a correctly-bound assertion. Two - # distinct, validly-signed tokens sharing one jti value is exactly - # the scenario jti replay tracking cares about, regardless of what - # else differs between them. - proxy = _make_proxy(config) - shared_jti = "jti-shared-client" - wrong_client = _mint_id_jag( - idp_key, client_id="some-other-client", jti=shared_jti - ) - - rejected = await _post_token(proxy, wrong_client) - assert rejected.status_code == 401 - assert rejected.json()["error"] == "invalid_grant" - - correct_client = _mint_id_jag(idp_key, client_id="mcp-client", jti=shared_jti) - accepted = await _post_token(proxy, correct_client, register=False) - assert accepted.status_code == 200 - - async def test_wrong_resource_binding_does_not_consume_jti( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - proxy = _make_proxy(config) - shared_jti = "jti-shared-resource" - wrong_resource = _mint_id_jag( - idp_key, - resource="https://other-server.example.com/mcp", - jti=shared_jti, - ) - - rejected = await _post_token(proxy, wrong_resource) - assert rejected.status_code == 401 - assert rejected.json()["error"] == "invalid_grant" - - correct_resource = _mint_id_jag(idp_key, jti=shared_jti) # default = RESOURCE - accepted = await _post_token(proxy, correct_resource, register=False) - assert accepted.status_code == 200 - - async def test_assertion_without_client_id_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - assertion = _mint_id_jag(idp_key, client_id=None) - - resp = await _post_token(proxy=_make_proxy(config), assertion=assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_assertion_for_other_resource_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # The signed resource claim governs: an assertion minted for server A - # must not be redeemable at server B behind the same IdP. - assertion = _mint_id_jag( - idp_key, resource="https://other-server.example.com/mcp" - ) - - resp = await _post_token(proxy=_make_proxy(config), assertion=assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_assertion_without_resource_rejected( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # When the proxy knows its resource URL, an assertion that names no - # resource cannot be audience-restricted per SEP-990 and is rejected. - assertion = _mint_id_jag(idp_key, resource=None) - - resp = await _post_token(proxy=_make_proxy(config), assertion=assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_resource_mismatch_rejected( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - ): - # RFC 8707: a token request naming a different resource must get - # invalid_target (mirrors the authorize() invariant), not a token - # for this server. Rejection happens before any JWKS fetch. - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key) - - resp = await _post_token( - proxy, assertion, resource="https://other-server.example.com/mcp" - ) - - assert resp.status_code == 400 - assert resp.json()["error"] == "invalid_target" - - async def test_matching_resource_accepted( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - ): - # JWKS served by the class's autouse _mock_jwks fixture. - proxy = _make_proxy(config) - assertion = _mint_id_jag(idp_key) - - resp = await _post_token(proxy, assertion, resource=f"{BASE_URL}/mcp") - - assert resp.status_code == 200 - - async def test_jti_cache_does_not_grow_past_capacity( - self, idp_key: RSAKeyPair, config: IdentityAssertion - ): - # Once the JTI cache is full of still-valid entries, further fresh - # assertions are rejected as overloaded WITHOUT being inserted, so the - # cache never grows beyond its cap. - proxy = _make_proxy(config) - validator = proxy._identity_assertion_validator - assert validator is not None - validator._jti_cache_max_size = 2 - future = time.time() + 120 - validator._jti_cache = {"filler-a": future, "filler-b": future} - - for i in range(3): - assertion = _mint_id_jag(idp_key, jti=f"fresh-{i}") - resp = await _post_token(proxy, assertion) - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - assert len(validator._jti_cache) == 2 - - -class TestAlgorithmConfig: - """Non-RS256 issuers work when `algorithm` is configured. - - Lives outside TestValidationMatrix because that class's autouse - `_mock_jwks` fixture pre-registers an RSA JWKS for the same URL, and - pytest-httpx serves first-registered responses first. - """ - - async def test_es256_issuer_supported_via_algorithm_config( - self, httpx_mock: HTTPXMock - ): - ec_key = jwk.ECKey.generate_key("P-256") - jwks_entry = ec_key.as_dict(private=False) - jwks_entry["kid"] = "idp-ec-1" - jwks_entry["alg"] = "ES256" - httpx_mock.add_response(url=JWKS_URI, json={"keys": [jwks_entry]}) - - now = int(time.time()) - header = {"alg": "ES256", "typ": ID_JAG_TYP, "kid": "idp-ec-1"} - payload = { - "iss": ISSUER, - "aud": BASE_URL, - "sub": "employee@acme-corp.com", - "exp": now + 120, - "iat": now, - "jti": "jti-es256-1", - "client_id": "mcp-client", - "resource": RESOURCE, - } - assertion = jwt.encode(header, payload, ec_key, algorithms=["ES256"]) - - es_config = IdentityAssertion( - trusted_issuers=[ISSUER], - jwks_uris={ISSUER: JWKS_URI}, - algorithm="ES256", - ) - resp = await _post_token(proxy=_make_proxy(es_config), assertion=assertion) - assert resp.status_code == 200 - - -class TestIssuerKeyDiscovery: - async def test_jwks_discovered_via_oidc( - self, idp_key: RSAKeyPair, httpx_mock: HTTPXMock - ): - # No explicit jwks_uris: the validator must discover the JWKS URI from - # the issuer's OIDC configuration document. - oidc_config_url = ISSUER.rstrip("/") + "/.well-known/openid-configuration" - httpx_mock.add_response(url=oidc_config_url, json={"jwks_uri": JWKS_URI}) - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - - proxy = _make_proxy(IdentityAssertion(trusted_issuers=[ISSUER])) - assertion = _mint_id_jag(idp_key) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 200 - assert resp.json()["access_token"] - - async def test_non_object_discovery_body_rejected( - self, idp_key: RSAKeyPair, httpx_mock: HTTPXMock - ): - # A discovery endpoint returning valid JSON that isn't an object (e.g. - # a bare array) must map to invalid_grant, not a 500 on `.get()`. - oidc_config_url = ISSUER.rstrip("/") + "/.well-known/openid-configuration" - httpx_mock.add_response(url=oidc_config_url, json=[]) - - proxy = _make_proxy(IdentityAssertion(trusted_issuers=[ISSUER])) - assertion = _mint_id_jag(idp_key) - - resp = await _post_token(proxy, assertion) - - assert resp.status_code == 401 - assert resp.json()["error"] == "invalid_grant" - - async def test_failed_discovery_backs_off( - self, idp_key: RSAKeyPair, httpx_mock: HTTPXMock - ): - # Discovery runs before signature verification, so repeated garbage - # with a trusted iss must not turn into an outbound HTTP call per - # request: after a failure, subsequent requests fast-fail without - # fetching until the cooldown elapses. - oidc_config_url = ISSUER.rstrip("/") + "/.well-known/openid-configuration" - httpx_mock.add_exception( - httpx2.ConnectError("connection refused"), url=oidc_config_url - ) - - proxy = _make_proxy(IdentityAssertion(trusted_issuers=[ISSUER])) - - first = await _post_token(proxy, _mint_id_jag(idp_key, jti="jti-d1")) - assert first.status_code == 401 - - second = await _post_token( - proxy, _mint_id_jag(idp_key, jti="jti-d2"), register=False - ) - assert second.status_code == 401 - # Only the FIRST request hit the network; the second fast-failed - # inside the cooldown window. - assert len(httpx_mock.get_requests()) == 1 - - -class TestRevocation: - async def test_revoked_id_jag_token_rejected( - self, - idp_key: RSAKeyPair, - httpx_mock: HTTPXMock, - ): - # ID-JAG access tokens are self-contained — nothing upstream knows - # them, so revocation must be tracked locally. After revoke_token, - # load_access_token rejects the token for its remaining lifetime. - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - proxy = _make_proxy( - IdentityAssertion(trusted_issuers=[ISSUER], jwks_uris={ISSUER: JWKS_URI}) - ) - resp = await _post_token(proxy, _mint_id_jag(idp_key)) - assert resp.status_code == 200 - issued = resp.json()["access_token"] - - loaded = await proxy.load_access_token(issued) - assert loaded is not None - - await proxy.revoke_token(loaded) - - assert await proxy.load_access_token(issued) is None - - -class TestGrantTypeEnforcement: - """The proxy dispatches the jwt-bearer grant itself, so it must enforce the - registered-grant-type constraint the SDK would otherwise apply: only clients - registered for the jwt-bearer grant may present an ID-JAG. DCR adds the grant - to registered clients when identity assertion is enabled.""" - - async def test_client_not_registered_for_jwt_bearer_rejected( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - ): - # The grant-type check rejects before any assertion validation, so no - # JWKS fetch occurs. - proxy = _make_proxy(config) - # A client registered only for the standard grants (e.g. before identity - # assertion was enabled). Stored directly so the DCR enabled-path does not - # add jwt-bearer for us. - await proxy._client_store.put( - key="mcp-client", - value=ProxyDCRClient( - client_id="mcp-client", - client_secret=None, - redirect_uris=[AnyUrl("http://localhost/callback")], - grant_types=["authorization_code", "refresh_token"], - token_endpoint_auth_method="none", - ), - ) - assertion = _mint_id_jag(idp_key, scope="read") - - resp = await _post_token(proxy, assertion, register=False) - - assert resp.status_code == 400 - assert resp.json()["error"] == "unsupported_grant_type" - - async def test_dcr_adds_jwt_bearer_when_enabled(self, config: IdentityAssertion): - proxy = _make_proxy(config) - await proxy.register_client( - OAuthClientInformationFull( - client_id="dcr-client", - redirect_uris=[AnyUrl("http://localhost/callback")], - grant_types=["authorization_code", "refresh_token"], - ) - ) - - client = await proxy.get_client("dcr-client") - - assert client is not None - assert JWT_BEARER_GRANT_TYPE in client.grant_types - - async def test_dcr_does_not_add_jwt_bearer_when_disabled(self): - proxy = _make_proxy(None) - await proxy.register_client( - OAuthClientInformationFull( - client_id="dcr-client", - redirect_uris=[AnyUrl("http://localhost/callback")], - grant_types=["authorization_code", "refresh_token"], - ) - ) - - client = await proxy.get_client("dcr-client") - - assert client is not None - assert JWT_BEARER_GRANT_TYPE not in client.grant_types - - async def test_dcr_registered_client_can_exchange( - self, - idp_key: RSAKeyPair, - config: IdentityAssertion, - httpx_mock: HTTPXMock, - ): - """A client that registers via DCR without the jwt-bearer grant can still - exchange an ID-JAG, because the enabled-path adds the grant on registration.""" - httpx_mock.add_response(url=JWKS_URI, json=_idp_jwks(idp_key)) - proxy = _make_proxy(config) - await proxy.register_client( - OAuthClientInformationFull( - client_id="mcp-client", - redirect_uris=[AnyUrl("http://localhost/callback")], - grant_types=["authorization_code", "refresh_token"], - ) - ) - assertion = _mint_id_jag(idp_key, scope="read") - - resp = await _post_token(proxy, assertion, register=False) - - assert resp.status_code == 200 - assert resp.json()["access_token"] diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index f5f15f5ab..f3b962bc4 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -1,16 +1,12 @@ """Tests for OAuth proxy initialization and configuration.""" import time -from unittest.mock import AsyncMock, patch from urllib.parse import parse_qs, urlparse import httpx2 import pytest from key_value.aio.stores.memory import MemoryStore -from mcp.shared.auth import OAuthClientInformationFull -from pydantic import AnyUrl from starlette.applications import Starlette -from starlette.testclient import TestClient from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import OAuthTransaction @@ -236,66 +232,12 @@ class TestOAuthProxyInitialization: metadata = response.json() assert metadata.get("client_id_metadata_document_supported") is True assert set(metadata.get("token_endpoint_auth_methods_supported")) == { + "client_secret_post", + "client_secret_basic", "private_key_jwt", "none", } - async def test_metadata_advertises_only_public_client_auth(self, jwt_verifier): - """The proxy authenticates every client as public, so metadata must - advertise `none` and must not claim secret-based methods it never enforces. - """ - proxy = OAuthProxy( - upstream_authorization_endpoint="https://auth.example.com/authorize", - upstream_token_endpoint="https://auth.example.com/token", - upstream_client_id="client-123", - upstream_client_secret="secret-456", - token_verifier=jwt_verifier, - base_url="https://api.example.com", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - enable_cimd=False, - ) - - app = Starlette(routes=proxy.get_routes()) - transport = httpx2.ASGITransport(app=app) - - async with httpx2.AsyncClient( - transport=transport, base_url="https://api.example.com" - ) as client: - response = await client.get("/.well-known/oauth-authorization-server") - - assert response.status_code == 200 - metadata = response.json() - assert set(metadata.get("token_endpoint_auth_methods_supported")) == {"none"} - - async def test_metadata_advertises_authorization_response_issuer_parameter( - self, jwt_verifier - ): - """OAuth metadata should advertise RFC 9207 authorization response issuers.""" - proxy = OAuthProxy( - upstream_authorization_endpoint="https://auth.example.com/authorize", - upstream_token_endpoint="https://auth.example.com/token", - upstream_client_id="client-123", - upstream_client_secret="secret-456", - token_verifier=jwt_verifier, - base_url="https://api.example.com", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - ) - - app = Starlette(routes=proxy.get_routes()) - transport = httpx2.ASGITransport(app=app) - - async with httpx2.AsyncClient( - transport=transport, base_url="https://api.example.com" - ) as client: - response = await client.get("/.well-known/oauth-authorization-server") - - assert response.status_code == 200 - metadata = response.json() - assert metadata["issuer"] == "https://api.example.com/" - assert metadata["authorization_response_iss_parameter_supported"] is True - class TestOptionalClientSecret: """Tests for OAuthProxy without upstream_client_secret.""" @@ -418,61 +360,6 @@ class TestIdpCallbackErrorForwarding: assert params["error"] == ["access_denied"] assert params["error_description"] == ["User denied access"] assert params["state"] == [client_state] - assert params["iss"] == ["https://myserver.com/"] - - async def test_error_redirect_does_not_duplicate_iss_already_in_redirect_uri( - self, oauth_proxy - ): - """RFC 9207 P2 regression: a registered redirect_uri may already - carry its own `iss` query parameter (e.g. a multi-tenant client - encoding its tenant in the callback URL). Forwarding an IdP error - must not append a second `iss` on top of it -- RFC 6749 §3.1 - forbids a response parameter appearing more than once -- and every - other query byte on the registered URI (a valueless `flag` and a - non-UTF-8 percent-encoded `sig`) must survive untouched. - """ - txn_id = "test-txn-dup-iss" - client_redirect_uri = ( - "http://localhost:12345/callback?iss=tenant&flag&sig=%FF%FE" - ) - client_state = "client-state-abc" - - transaction = OAuthTransaction( - txn_id=txn_id, - client_id="test-client", - client_redirect_uri=client_redirect_uri, - client_state=client_state, - code_challenge=None, - code_challenge_method="S256", - scopes=["read"], - created_at=time.time(), - ) - await oauth_proxy._transaction_store.put(key=txn_id, value=transaction) - - app = Starlette(routes=oauth_proxy.get_routes()) - transport = httpx2.ASGITransport(app=app) - - async with httpx2.AsyncClient( - transport=transport, - base_url="https://myserver.com", - follow_redirects=False, - ) as client: - response = await client.get( - f"/auth/callback?error=access_denied&state={txn_id}" - ) - - assert response.status_code == 302 - location = response.headers["location"] - query = urlparse(location).query - params = parse_qs(query) - - # Exactly one `iss`, corrected to the canonical value -- a - # duplicate would make this list have length 2. - assert params["iss"] == ["https://myserver.com/"] - # Other query bytes from the registered redirect_uri survive - # byte-for-byte. - assert "flag" in query - assert "sig=%FF%FE" in query async def test_error_with_unsafe_transaction_redirect_returns_html_error( self, oauth_proxy @@ -525,132 +412,3 @@ class TestIdpCallbackErrorForwarding: ) assert response.status_code == 400 - - -class TestIdpCallbackSuccessForwarding: - """Tests for the success (`code`) path in the IdP callback.""" - - async def test_success_redirect_does_not_duplicate_iss_already_in_redirect_uri( - self, jwt_verifier - ): - """RFC 9207 P2 regression at the success-redirect call site: a - registered redirect_uri already carrying `iss` must end up with - exactly one `iss` (the canonical value) after the proxy forwards - the exchanged authorization code, and every other query byte on the - registered URI must survive untouched. - """ - # Consent is disabled here because this test exercises callback - # forwarding, not the consent-binding-cookie check that the - # standard consent flow additionally requires. - oauth_proxy = OAuthProxy( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id="test-client-id", - upstream_client_secret="test-client-secret", - token_verifier=jwt_verifier, - base_url="https://myserver.com", - redirect_path="/auth/callback", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - require_authorization_consent=False, - ) - - client_id = "success-dup-iss-client" - client_redirect_uri = ( - "http://localhost:12345/callback?iss=tenant&flag&sig=%FF%FE" - ) - client_info = OAuthClientInformationFull( - client_id=client_id, - client_secret="test-secret", - redirect_uris=[AnyUrl(client_redirect_uri)], - ) - await oauth_proxy.register_client(client_info) - - txn_id = "test-txn-success-dup-iss" - transaction = OAuthTransaction( - txn_id=txn_id, - client_id=client_id, - client_redirect_uri=client_redirect_uri, - client_state="client-state-success", - code_challenge=None, - code_challenge_method="S256", - scopes=["read"], - created_at=time.time(), - ) - await oauth_proxy._transaction_store.put(key=txn_id, value=transaction) - - app = Starlette(routes=oauth_proxy.get_routes()) - transport = httpx2.ASGITransport(app=app) - - with patch( - "fastmcp.server.auth.oauth_proxy.proxy.AsyncOAuth2Client" - ) as MockClient: - mock_client = AsyncMock() - mock_client.fetch_token = AsyncMock( - return_value={ - "access_token": "upstream-access-token", - "refresh_token": "upstream-refresh-token", - "expires_in": 3600, - "token_type": "Bearer", - } - ) - MockClient.return_value = mock_client - - async with httpx2.AsyncClient( - transport=transport, - base_url="https://myserver.com", - follow_redirects=False, - ) as client: - response = await client.get( - f"/auth/callback?code=idp-authorization-code&state={txn_id}" - ) - - assert response.status_code == 302 - location = response.headers["location"] - query = urlparse(location).query - params = parse_qs(query) - - assert "code" in params - assert params["state"] == ["client-state-success"] - # Exactly one `iss`, corrected to the canonical value -- a - # duplicate would make this list have length 2. - assert params["iss"] == ["https://myserver.com/"] - # Other query bytes from the registered redirect_uri survive - # byte-for-byte. - assert "flag" in query - assert "sig=%FF%FE" in query - - -class TestCIMDTokenEndpointAudience: - """The `aud` expected on a CIMD assertion matches the advertised token endpoint.""" - - @pytest.mark.parametrize( - "base_url", - ["https://api.example.com", "https://api.example.com/api"], - ) - def test_token_endpoint_url_matches_advertised_metadata( - self, jwt_verifier, base_url: str - ): - """A bare-authority base_url must not expect `aud` of `https://host//token`. - - CIMD is enabled by default, and a spec-following client binds its - private_key_jwt assertion to the `token_endpoint` the metadata - advertises. If the proxy expects a different string, every such client - is rejected with invalid_client. - """ - proxy = OAuthProxy( - upstream_authorization_endpoint="https://auth.example.com/authorize", - upstream_token_endpoint="https://auth.example.com/token", - upstream_client_id="client-123", - upstream_client_secret="secret-456", - token_verifier=jwt_verifier, - base_url=base_url, - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - ) - - app = Starlette(routes=proxy.get_routes(mcp_path="/mcp")) - with TestClient(app) as client: - metadata = client.get("/.well-known/oauth-authorization-server").json() - - assert proxy.token_endpoint_url == metadata["token_endpoint"] diff --git a/tests/server/auth/oauth_proxy/test_tokens.py b/tests/server/auth/oauth_proxy/test_tokens.py index 0e552d615..0ecb42afe 100644 --- a/tests/server/auth/oauth_proxy/test_tokens.py +++ b/tests/server/auth/oauth_proxy/test_tokens.py @@ -3,7 +3,6 @@ import logging import time from unittest.mock import AsyncMock, Mock, patch -from urllib.parse import parse_qs, urlparse import pytest from key_value.aio.stores.memory import MemoryStore @@ -250,60 +249,6 @@ class TestOAuthProxyTokenEndpointAuth: mock_client.fetch_token.assert_awaited_once() mock_client.aclose.assert_awaited_once() - async def test_callback_redirect_includes_proxy_issuer(self, jwt_verifier): - proxy = OAuthProxy( - upstream_authorization_endpoint="https://oauth.example.com/authorize", - upstream_token_endpoint="https://oauth.example.com/token", - upstream_client_id="client-id", - upstream_client_secret="client-secret", - token_verifier=jwt_verifier, - base_url="https://proxy.example.com", - require_authorization_consent=False, - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - ) - - await proxy._transaction_store.put( - key="txn-id", - value=OAuthTransaction( - txn_id="txn-id", - client_id="test-client", - client_redirect_uri="http://localhost:12345/callback", - client_state="client-state", - code_challenge="", - code_challenge_method="S256", - scopes=["read"], - created_at=time.time(), - ), - ) - - mock_request = Mock() - mock_request.query_params = {"code": "idp-code", "state": "txn-id"} - mock_request.cookies = {} - - mock_client = AsyncMock() - mock_client.fetch_token = AsyncMock( - return_value={ - "access_token": "upstream-access-token", - "refresh_token": "upstream-refresh-token", - "expires_in": 3600, - "token_type": "Bearer", - } - ) - - with patch.object( - proxy, "_create_upstream_oauth_client", return_value=mock_client - ): - response = await proxy._handle_idp_callback(mock_request) - - assert response.status_code == 302 - location = response.headers["location"] - query_params = parse_qs(urlparse(location).query) - assert "code" in query_params - assert query_params["state"] == ["client-state"] - assert query_params["iss"] == ["https://proxy.example.com/"] - mock_client.aclose.assert_awaited_once() - async def test_callback_rejects_unsafe_transaction_redirect(self, jwt_verifier): proxy = OAuthProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", diff --git a/tests/server/auth/providers/test_auth0_mcp.py b/tests/server/auth/providers/test_auth0_mcp.py deleted file mode 100644 index 868b7f553..000000000 --- a/tests/server/auth/providers/test_auth0_mcp.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Tests for Auth0 MCP resource server provider.""" - -from unittest.mock import patch - -import httpx2 -import pytest - -from fastmcp import FastMCP -from fastmcp.server.auth.oidc_proxy import OIDCConfiguration -from fastmcp.server.auth.providers.auth0 import Auth0JWTVerifier, Auth0MCPProvider -from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair - -TEST_CONFIG_URL = "https://example.us.auth0.com/.well-known/openid-configuration" -TEST_BASE_URL = "http://127.0.0.1:8000" -TEST_ISSUER = "https://example.us.auth0.com/" -TEST_JWKS_URI = "https://example.us.auth0.com/.well-known/jwks.json" - - -@pytest.fixture -def valid_oidc_configuration_dict(): - return { - "issuer": TEST_ISSUER, - "authorization_endpoint": "https://example.us.auth0.com/authorize", - "token_endpoint": "https://example.us.auth0.com/oauth/token", - "jwks_uri": TEST_JWKS_URI, - "registration_endpoint": "https://example.us.auth0.com/oidc/register", - "response_types_supported": ["code"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"], - } - - -class TestAuth0JWTVerifier: - def test_extract_scopes_includes_permissions(self): - verifier = Auth0JWTVerifier( - jwks_uri=TEST_JWKS_URI, - issuer=TEST_ISSUER, - ) - scopes = verifier._extract_scopes( - {"scope": "openid", "permissions": ["tool:whoami", "tool:greet"]} - ) - assert scopes == ["openid", "tool:whoami", "tool:greet"] - - def test_extract_scopes_permissions_string(self): - verifier = Auth0JWTVerifier( - jwks_uri=TEST_JWKS_URI, - issuer=TEST_ISSUER, - ) - scopes = verifier._extract_scopes({"permissions": "tool:whoami tool:greet"}) - assert scopes == ["tool:whoami", "tool:greet"] - - async def test_verify_token_accepts_permissions_as_required_scopes( - self, rsa_key_pair: RSAKeyPair - ): - key_pair = rsa_key_pair - verifier = Auth0JWTVerifier( - public_key=key_pair.public_key, - issuer=TEST_ISSUER, - required_scopes=["tool:echo"], - ) - token = key_pair.create_token( - subject="user_123", - issuer=TEST_ISSUER, - additional_claims={"permissions": ["tool:echo"]}, - ) - - access_token = await verifier.load_access_token(token) - assert access_token is not None - assert access_token.client_id == "user_123" - - async def test_verify_token_rejects_missing_permissions( - self, rsa_key_pair: RSAKeyPair - ): - key_pair = rsa_key_pair - verifier = Auth0JWTVerifier( - public_key=key_pair.public_key, - issuer=TEST_ISSUER, - required_scopes=["tool:echo"], - ) - token = key_pair.create_token( - subject="user_123", - issuer=TEST_ISSUER, - additional_claims={"permissions": ["tool:other"]}, - ) - - access_token = await verifier.load_access_token(token) - assert access_token is None - - -class TestAuth0MCPProviderInit: - def test_init_from_oidc_discovery(self, valid_oidc_configuration_dict): - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - ) - - mock_get.assert_called_once() - assert provider.issuer == "https://example.us.auth0.com" - assert str(provider.base_url) == f"{TEST_BASE_URL}/" - verifier = provider.token_verifier - assert isinstance(verifier, Auth0JWTVerifier) - assert verifier.jwks_uri == TEST_JWKS_URI - assert verifier.issuer == TEST_ISSUER - assert len(provider.authorization_servers) == 1 - assert ( - str(provider.authorization_servers[0]).rstrip("/") - == "https://example.us.auth0.com" - ) - - def test_custom_token_verifier_not_replaced(self, valid_oidc_configuration_dict): - custom = JWTVerifier( - jwks_uri=TEST_JWKS_URI, - issuer=TEST_ISSUER, - audience="https://custom.example.com/mcp", - ) - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - token_verifier=custom, - ) - - assert provider.token_verifier is custom - assert provider._auto_bind_audience is False - - -class TestAuth0MCPAudienceBinding: - def test_audience_binds_on_set_mcp_path(self, valid_oidc_configuration_dict): - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - ) - - verifier = provider.token_verifier - assert isinstance(verifier, Auth0JWTVerifier) - assert verifier.audience is None - - provider.set_mcp_path("/mcp") - assert verifier.audience == "http://127.0.0.1:8000/mcp" - - def test_audience_respects_resource_base_url(self, valid_oidc_configuration_dict): - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url="https://oauth.example.com", - resource_base_url="https://api.example.com", - ) - - provider.set_mcp_path("/mcp") - verifier = provider.token_verifier - assert isinstance(verifier, Auth0JWTVerifier) - assert verifier.audience == "https://api.example.com/mcp" - - def test_custom_verifier_audience_not_overwritten( - self, valid_oidc_configuration_dict - ): - custom_audience = "https://other.example.com" - custom = JWTVerifier( - jwks_uri=TEST_JWKS_URI, - issuer=TEST_ISSUER, - audience=custom_audience, - ) - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - token_verifier=custom, - ) - provider.set_mcp_path("/mcp") - - assert custom.audience == custom_audience - - def test_set_mcp_path_none_binds_to_base_url(self, valid_oidc_configuration_dict): - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - ) - - provider.set_mcp_path(None) - - verifier = provider.token_verifier - assert isinstance(verifier, Auth0JWTVerifier) - assert verifier.audience == "http://127.0.0.1:8000/" - - def test_audience_binds_through_http_app(self, valid_oidc_configuration_dict): - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - auth = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - ) - mcp = FastMCP("test", auth=auth) - mcp.http_app(path="/mcp") - - verifier = auth.token_verifier - assert isinstance(verifier, Auth0JWTVerifier) - assert verifier.audience == "http://127.0.0.1:8000/mcp" - - -class TestAuth0MCPMetadataForwarding: - async def test_forwards_authorization_server_metadata( - self, valid_oidc_configuration_dict, monkeypatch - ): - metadata_payload = { - "issuer": TEST_ISSUER, - "authorization_endpoint": "https://example.us.auth0.com/authorize", - "token_endpoint": "https://example.us.auth0.com/oauth/token", - "registration_endpoint": "https://example.us.auth0.com/oidc/register", - } - - class DummyResponse: - def __init__(self, payload): - self._payload = payload - - def raise_for_status(self): - return None - - def json(self): - return self._payload - - class DummyAsyncClient: - last_url: str | None = None - - def __init__(self, *args, **kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - return None - - async def get(self, url): - DummyAsyncClient.last_url = url - return DummyResponse(metadata_payload) - - real_httpx_client = httpx2.AsyncClient - - monkeypatch.setattr( - "fastmcp.server.auth.providers.auth0.httpx2.AsyncClient", - DummyAsyncClient, - ) - - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - ) - - mcp = FastMCP("test", auth=provider) - app = mcp.http_app() - - async with real_httpx_client( - transport=httpx2.ASGITransport(app=app), - base_url=TEST_BASE_URL, - ) as client: - response = await client.get("/.well-known/oauth-authorization-server") - - assert response.status_code == 200 - assert response.json() == metadata_payload - assert ( - DummyAsyncClient.last_url - == "https://example.us.auth0.com/.well-known/oauth-authorization-server" - ) - - -class TestAuth0MCPIntegration: - async def test_unauthenticated_mcp_request_returns_401( - self, valid_oidc_configuration_dict - ): - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - ) - - mcp = FastMCP("test-server", auth=provider) - - @mcp.tool - def echo(message: str) -> str: - return message - - app = mcp.http_app() - - async with httpx2.AsyncClient( - transport=httpx2.ASGITransport(app=app), - base_url=TEST_BASE_URL, - ) as client: - response = await client.post( - "/mcp", - json={"jsonrpc": "2.0", "method": "tools/list", "id": 1}, - headers={"Content-Type": "application/json"}, - ) - - assert response.status_code == 401 - - async def test_no_register_proxy_route(self, valid_oidc_configuration_dict): - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - ) - - mcp = FastMCP("test-server", auth=provider) - app = mcp.http_app() - - async with httpx2.AsyncClient( - transport=httpx2.ASGITransport(app=app), - base_url=TEST_BASE_URL, - ) as client: - response = await client.post( - "/register", - json={"client_name": "Test", "redirect_uris": ["http://localhost/cb"]}, - headers={"Content-Type": "application/json"}, - ) - - assert response.status_code == 404 - - async def test_protected_resource_metadata(self, valid_oidc_configuration_dict): - with patch( - "fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - mock_get.return_value = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - provider = Auth0MCPProvider( - config_url=TEST_CONFIG_URL, - base_url=TEST_BASE_URL, - ) - - mcp = FastMCP("test-server", auth=provider) - app = mcp.http_app() - - async with httpx2.AsyncClient( - transport=httpx2.ASGITransport(app=app), - base_url=TEST_BASE_URL, - ) as client: - response = await client.get("/.well-known/oauth-protected-resource/mcp") - - assert response.status_code == 200 - data = response.json() - assert data["resource"] == f"{TEST_BASE_URL}/mcp" - assert data["authorization_servers"] == [TEST_ISSUER] diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 704af3ab4..1bca397dd 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -222,10 +222,10 @@ class TestAzureProvider: assert verifier.required_scopes == [".default"] async def test_token_accepted_with_client_id_audience( - self, memory_storage: MemoryStore, rsa_key_pair: RSAKeyPair + self, memory_storage: MemoryStore ): """Azure AD v2 tokens use the bare client_id as aud — must be accepted.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() provider = AzureProvider( client_id="test_client", client_secret="test_secret", @@ -252,10 +252,10 @@ class TestAzureProvider: assert result is not None async def test_token_accepted_with_identifier_uri_audience( - self, memory_storage: MemoryStore, rsa_key_pair: RSAKeyPair + self, memory_storage: MemoryStore ): """Azure AD v1 tokens use the identifier_uri as aud — must be accepted.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() provider = AzureProvider( client_id="test_client", client_secret="test_secret", @@ -282,10 +282,10 @@ class TestAzureProvider: assert result is not None async def test_token_rejected_with_wrong_audience( - self, memory_storage: MemoryStore, rsa_key_pair: RSAKeyPair + self, memory_storage: MemoryStore ): """Tokens for a different application must be rejected.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() provider = AzureProvider( client_id="test_client", client_secret="test_secret", @@ -888,11 +888,9 @@ class TestAzureProviderTokenIssuer: assert isinstance(provider._token_validator, JWTVerifier) assert provider._token_validator.issuer == custom_issuer - async def test_explicit_issuer_enforced( - self, memory_storage: MemoryStore, rsa_key_pair: RSAKeyPair - ): + async def test_explicit_issuer_enforced(self, memory_storage: MemoryStore): """With an explicit token_issuer, wrong issuers are rejected.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() expected = "https://expected.issuer.com/v2.0" provider = AzureProvider( client_id="test_client", @@ -1111,10 +1109,10 @@ class TestAzureProviderFromB2C: assert "B2C_1A_SIGNUP_SIGNIN" in provider._upstream_token_endpoint async def test_b2c_token_accepted_with_any_issuer( - self, memory_storage: MemoryStore, rsa_key_pair: RSAKeyPair + self, memory_storage: MemoryStore ): """B2C provider (issuer=None) accepts tokens from any issuer.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() provider = AzureProvider.from_b2c( tenant_name="mytenant", policy_name="B2C_1_susi", @@ -1141,10 +1139,10 @@ class TestAzureProviderFromB2C: assert result is not None async def test_b2c_token_rejected_with_wrong_audience( - self, memory_storage: MemoryStore, rsa_key_pair: RSAKeyPair + self, memory_storage: MemoryStore ): """B2C provider still rejects tokens with wrong audience.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() provider = AzureProvider.from_b2c( tenant_name="mytenant", policy_name="B2C_1_susi", diff --git a/tests/server/auth/providers/test_azure_scopes.py b/tests/server/auth/providers/test_azure_scopes.py index 49c302cf9..5f1bb6b18 100644 --- a/tests/server/auth/providers/test_azure_scopes.py +++ b/tests/server/auth/providers/test_azure_scopes.py @@ -425,8 +425,8 @@ class TestAzureJWTVerifier: assert verifier.algorithm == "RS256" assert verifier.required_scopes == ["access_as_user"] - async def test_validates_short_form_scopes(self, rsa_key_pair: RSAKeyPair): - key_pair = rsa_key_pair + async def test_validates_short_form_scopes(self): + key_pair = RSAKeyPair.generate() verifier = AzureJWTVerifier( client_id="my-client-id", tenant_id="my-tenant-id", @@ -446,11 +446,9 @@ class TestAzureJWTVerifier: assert result is not None assert "access_as_user" in result.scopes - async def test_validates_token_with_client_id_audience( - self, rsa_key_pair: RSAKeyPair - ): + async def test_validates_token_with_client_id_audience(self): """Azure AD v2 tokens use the bare client_id GUID as audience.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() verifier = AzureJWTVerifier( client_id="my-client-id", tenant_id="my-tenant-id", @@ -469,11 +467,9 @@ class TestAzureJWTVerifier: assert result is not None assert "access_as_user" in result.scopes - async def test_validates_token_with_custom_identifier_uri_audience( - self, rsa_key_pair: RSAKeyPair - ): + async def test_validates_token_with_custom_identifier_uri_audience(self): """Custom identifier_uri (e.g. Bicep deployments) accepted as audience.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() verifier = AzureJWTVerifier( client_id="my-client-id", tenant_id="my-tenant-id", @@ -493,9 +489,9 @@ class TestAzureJWTVerifier: assert result is not None assert "read" in result.scopes - async def test_rejects_token_with_wrong_audience(self, rsa_key_pair: RSAKeyPair): + async def test_rejects_token_with_wrong_audience(self): """Tokens for a different application must be rejected.""" - key_pair = rsa_key_pair + key_pair = RSAKeyPair.generate() verifier = AzureJWTVerifier( client_id="my-client-id", tenant_id="my-tenant-id", @@ -524,15 +520,6 @@ class TestAzureJWTVerifier: "api://my-client-id/write", ] - def test_translates_arbitrary_challenge_scopes(self): - verifier = AzureJWTVerifier( - client_id="my-client-id", - tenant_id="my-tenant-id", - required_scopes=["read"], - ) - - assert verifier.get_challenge_scopes(["admin"]) == ["api://my-client-id/admin"] - def test_already_prefixed_scopes_pass_through(self): verifier = AzureJWTVerifier( client_id="my-client-id", diff --git a/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py index ff15235b0..ba41203a7 100644 --- a/tests/server/auth/providers/test_http_client.py +++ b/tests/server/auth/providers/test_http_client.py @@ -124,6 +124,10 @@ class TestIntrospectionHttpClient: class TestJWTVerifierHttpClient: """Test http_client parameter on JWTVerifier.""" + @pytest.fixture(scope="class") + def rsa_key_pair(self) -> RSAKeyPair: + return RSAKeyPair.generate() + @pytest.fixture def shared_client(self) -> httpx2.AsyncClient: return httpx2.AsyncClient(timeout=30) diff --git a/tests/server/auth/providers/test_introspection.py b/tests/server/auth/providers/test_introspection.py index f96e13d22..047a3dee0 100644 --- a/tests/server/auth/providers/test_introspection.py +++ b/tests/server/auth/providers/test_introspection.py @@ -138,7 +138,6 @@ class TestIntrospectionTokenVerifier: assert access_token.client_id == "user-123" assert access_token.scopes == ["read", "write"] assert access_token.expires_at is not None - assert access_token.subject == "user-123" assert access_token.claims["active"] is True assert access_token.claims["username"] == "testuser" @@ -373,7 +372,6 @@ class TestIntrospectionTokenVerifier: assert access_token is not None assert access_token.client_id == "user-456" - assert access_token.subject == "user-456" async def test_client_id_defaults_to_unknown( self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock @@ -392,7 +390,6 @@ class TestIntrospectionTokenVerifier: assert access_token is not None assert access_token.client_id == "unknown" - assert access_token.subject is None def test_initialization_with_client_secret_post(self): """Test verifier initialization with client_secret_post method.""" diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py index b3f268806..5d18705b1 100644 --- a/tests/server/auth/test_authorization.py +++ b/tests/server/auth/test_authorization.py @@ -8,11 +8,10 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.exceptions import AuthorizationError, InsufficientScopeError +from fastmcp.exceptions import AuthorizationError from fastmcp.server.auth import ( AccessToken, AuthContext, - require_roles, require_scopes, restrict_tag, run_auth_checks, @@ -20,7 +19,6 @@ from fastmcp.server.auth import ( from fastmcp.server.middleware import AuthMiddleware from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig, TransformedTool -from fastmcp.utilities.authorization import scope_requirements from fastmcp.utilities.versions import VersionSpec # ============================================================================= @@ -28,17 +26,14 @@ from fastmcp.utilities.versions import VersionSpec # ============================================================================= -def make_token( - scopes: list[str] | None = None, - claims: dict | None = None, -) -> AccessToken: +def make_token(scopes: list[str] | None = None) -> AccessToken: """Create a test access token.""" return AccessToken( token="test-token", client_id="test-client", scopes=scopes or [], expires_at=None, - claims=claims or {}, + claims={}, ) @@ -91,155 +86,6 @@ class TestRequireScopes: assert check(ctx) is False -# ============================================================================= -# Tests for require_roles -# ============================================================================= - - -KEYCLOAK = {"realm_access": {"roles": ["admin", "viewer"]}} - - -def keycloak_roles(claims: dict) -> list[str]: - return claims["realm_access"]["roles"] - - -class TestRequireRoles: - @pytest.mark.parametrize( - "claims, extract", - [ - (KEYCLOAK, keycloak_roles), - ({"roles": ["admin"]}, lambda c: c["roles"]), - ({"cognito:groups": ["admin"]}, lambda c: c["cognito:groups"]), - ({"permissions": ["admin"]}, lambda c: c["permissions"]), - ( - {"https://app.example.com/roles": ["admin"]}, - lambda c: c["https://app.example.com/roles"], - ), - ], - ) - def test_reads_roles_from_provider_specific_claim(self, claims, extract): - ctx = AuthContext(token=make_token(claims=claims), component=make_tool()) - assert require_roles("admin", extract=extract)(ctx) is True - - def test_requires_all_roles(self): - ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool()) - check = require_roles("admin", "viewer", extract=keycloak_roles) - assert check(ctx) is True - - def test_denies_when_one_role_missing(self): - ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool()) - check = require_roles("admin", "auditor", extract=keycloak_roles) - assert check(ctx) is False - - def test_denies_without_token(self): - ctx = AuthContext(token=None, component=make_tool()) - assert require_roles("admin", extract=keycloak_roles)(ctx) is False - - @pytest.mark.parametrize( - "claims", - [{}, {"realm_access": {}}, {"realm_access": None}, {"realm_access": []}], - ) - def test_denies_when_claim_absent_or_malformed(self, claims): - """A token without the claim is an ordinary denial, not a broken check.""" - ctx = AuthContext(token=make_token(claims=claims), component=make_tool()) - assert require_roles("admin", extract=keycloak_roles)(ctx) is False - - def test_rejects_empty_role_list(self): - """A check with no roles would admit any authenticated caller.""" - with pytest.raises(ValueError, match="at least one role"): - require_roles(extract=keycloak_roles) - - def test_is_opaque_to_scope_shortfall(self): - """Roles cannot be requested via OAuth, so they yield no step-up.""" - ctx = AuthContext(token=make_token(claims=KEYCLOAK), component=make_tool()) - check = require_roles("auditor", extract=keycloak_roles) - assert scope_requirements(check, ctx) is None - - def test_suppresses_shortfall_disclosure_of_sibling_scope_checks(self): - """One opaque check withholds the whole list's scope requirements.""" - token = make_token(scopes=["read"], claims=KEYCLOAK) - ctx = AuthContext(token=token, component=make_tool()) - checks = [ - require_scopes("write"), - require_roles("admin", extract=keycloak_roles), - ] - assert scope_requirements(checks, ctx) is None - assert scope_requirements([require_scopes("write")], ctx) == ["write"] - - @pytest.mark.parametrize( - "required, expected", - [("admin", True), ("a", False), ("dmin", False)], - ) - def test_scalar_role_claim_is_one_role(self, required: str, expected: bool): - """A provider storing one role as a string must not be iterated. - - `str` satisfies `Iterable[str]`, so a bare "admin" would otherwise - become the character set {a, d, m, i, n} — denying the "admin" it - plainly grants and granting any single character it contains. - """ - ctx = AuthContext( - token=make_token(claims={"role": "admin"}), component=make_tool() - ) - check = require_roles(required, extract=lambda c: c["role"]) - assert check(ctx) is expected - - async def test_role_denial_suppresses_scope_challenge(self): - """A caller blocked by their role is not told to obtain a scope.""" - mcp = FastMCP( - middleware=[ - AuthMiddleware( - auth=[ - require_scopes("api"), - require_roles("admin", extract=keycloak_roles), - ] - ) - ] - ) - - @mcp.tool - def t() -> str: - return "ok" - - token = make_token(scopes=["read"], claims={"realm_access": {"roles": ["v"]}}) - tok = set_token(token) - try: - with pytest.raises(AuthorizationError) as exc_info: - await mcp.call_tool("t", {}) - finally: - auth_context_var.reset(tok) - - assert not isinstance(exc_info.value, InsufficientScopeError) - - async def test_passing_role_still_allows_scope_challenge(self): - """Mixing the two checks does not disable step-up on its own.""" - mcp = FastMCP( - middleware=[ - AuthMiddleware( - auth=[ - require_scopes("api"), - require_roles("admin", extract=keycloak_roles), - ] - ) - ] - ) - - @mcp.tool - def t() -> str: - return "ok" - - token = make_token( - scopes=["read"], claims={"realm_access": {"roles": ["admin"]}} - ) - tok = set_token(token) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("t", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["api"] - - # ============================================================================= # Tests for restrict_tag # ============================================================================= @@ -979,11 +825,6 @@ class TestAuthMiddlewareCallTool: class TestAuthMiddlewareVersionedRequests: - # The resource/template/prompt denial cases are pinned to legacy: the - # authorization error message is surfaced to the client on the handshake - # era, but the modern server runner masks the raised denial as a generic - # "Internal server error". The tool case surfaces via an isError result and - # stays era-neutral. async def test_middleware_blocks_explicit_restricted_tool_version(self): """AuthMiddleware should check the requested tool version.""" mcp = make_restricted_tag_server() @@ -1037,7 +878,7 @@ class TestAuthMiddlewareVersionedRequests: tok = set_token(make_token(scopes=["read"])) try: - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: with pytest.raises(Exception, match="authorization|insufficient"): await client.read_resource("data://info", version="1.0") finally: @@ -1057,7 +898,7 @@ class TestAuthMiddlewareVersionedRequests: tok = set_token(make_token(scopes=["read"])) try: - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: with pytest.raises(Exception, match="authorization|insufficient"): await client.read_resource("data://items/123", version="1.0") finally: @@ -1077,7 +918,7 @@ class TestAuthMiddlewareVersionedRequests: tok = set_token(make_token(scopes=["read"])) try: - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: with pytest.raises(Exception, match="authorization|insufficient"): await client.get_prompt("greet", version="1.0") finally: @@ -1101,12 +942,6 @@ class TestComponentAuthDenialMessage: The message must stay ambiguous ("not found or not authorized") rather than asserting the component does not exist (misleading) or that it exists but is forbidden (leaks existence to unauthorized callers). - - The resource/prompt cases are pinned to legacy: their denial message is - surfaced only on the handshake era, where the read/get path converts the - error to a client-visible message; the modern server runner masks it as a - generic "Internal server error". The tool case surfaces via an isError - result and stays era-neutral. """ async def test_call_tool_denied_by_component_auth(self): @@ -1137,7 +972,7 @@ class TestComponentAuthDenialMessage: token = make_token(scopes=["read"]) tok = set_token(token) try: - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: with pytest.raises(Exception) as exc_info: await client.read_resource("data://secret") message = str(exc_info.value) @@ -1155,331 +990,10 @@ class TestComponentAuthDenialMessage: token = make_token(scopes=["read"]) tok = set_token(token) try: - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: with pytest.raises(Exception) as exc_info: await client.get_prompt("secret_prompt") message = str(exc_info.value) assert "not found or not authorized" in message finally: auth_context_var.reset(tok) - - -# ============================================================================= -# Tests for component-level scope step-up signalling (SEP-2350) -# ============================================================================= - - -class TestInsufficientScopeSignal: - """A scope shortfall on a globally-authorized component is surfaced as an - ``InsufficientScopeError`` naming the unmet scopes, the component-level - analog of the transport-level ``insufficient_scope`` challenge. The named - scopes are only those the token lacks, so an existing grant accumulates - rather than being replaced when the caller re-authorizes. - """ - - async def test_call_tool_missing_scope_names_required_scope(self): - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=["read"])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["api"] - assert "api" in str(exc_info.value) - - async def test_insufficient_scope_error_is_authorization_error(self): - # Existing `except AuthorizationError` sites must still catch it. - assert issubclass(InsufficientScopeError, AuthorizationError) - - async def test_call_tool_sufficient_scope_passes(self): - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=["api", "read"])) - try: - result = await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert result.content[0].text == "ok" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_shortfall_names_only_unmet_scopes(self): - # The token already carries "read"; only the missing "api" is named, so a - # re-authorization accumulates scopes rather than dropping "read". - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("read", "api"))]) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=["read"])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["api"] - - async def test_missing_token_is_not_insufficient_scope(self): - # No token is an authentication failure (RFC 6750 §3.1), not a scope - # shortfall: it must not be turned into an insufficient_scope signal. - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) - - @mcp.tool - def api_tool() -> str: - return "ok" - - with pytest.raises(AuthorizationError) as exc_info: - await mcp.call_tool("api_tool", {}) - - assert not isinstance(exc_info.value, InsufficientScopeError) - - async def test_non_scope_denial_stays_opaque_and_names_no_scope(self): - # A non-scope check (e.g. a custom tenant policy) fails first and - # short-circuits before the scope check runs. The denial must stay a - # plain AuthorizationError and must NOT disclose or request the "admin" - # scope for a component the caller could not otherwise reach. - def deny_tenant(ctx: AuthContext) -> bool: - return False - - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=[deny_tenant, require_scopes("admin")]), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=["read"])) - try: - with pytest.raises(AuthorizationError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert not isinstance(exc_info.value, InsufficientScopeError) - assert "admin" not in str(exc_info.value) - - async def test_shortfall_unions_every_unmet_scope_check(self): - # Naming only the first failing check would strand the caller in a - # step-up loop: they obtain "read", retry, and are denied for "write". - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=[require_scopes("read"), require_scopes("write")]), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=[])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["read", "write"] - - async def test_shortfall_union_drops_already_granted_scope(self): - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=[require_scopes("read"), require_scopes("write")]), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=["read"])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["write"] - - async def test_shortfall_unions_across_middleware_chain(self): - # Scope requirements split across two AuthMiddleware instances. The - # outer one raises before the inner ever runs, so its shortfall has to - # account for the inner requirement or the caller loops. - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=require_scopes("admin")), - AuthMiddleware(auth=require_scopes("write")), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=[])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["admin", "write"] - - async def test_chain_shortfall_drops_already_granted_scope(self): - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=require_scopes("admin")), - AuthMiddleware(auth=require_scopes("write")), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=["admin"])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["write"] - - async def test_opaque_denial_in_chain_stays_opaque(self): - # An opaque check denies in the outer middleware; the inner middleware's - # scope requirement must not be disclosed. - def deny_tenant(ctx: AuthContext) -> bool: - return False - - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=deny_tenant), - AuthMiddleware(auth=require_scopes("write")), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=[])) - try: - with pytest.raises(AuthorizationError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert not isinstance(exc_info.value, InsufficientScopeError) - assert "write" not in str(exc_info.value) - - async def test_chain_shortfall_withholds_scopes_of_opaque_sibling(self): - # The outer middleware has a real scope shortfall, but the inner one - # pairs its scope requirement with an opaque check whose verdict is - # unknown. That layer's scope must not be disclosed. - def opaque_policy(ctx: AuthContext) -> bool: - return True - - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=require_scopes("admin")), - AuthMiddleware(auth=[opaque_policy, require_scopes("write")]), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=[])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["admin"] - - async def test_chain_shortfall_stops_at_unevaluated_opaque_layer(self): - # The opaque middleware sits between two scope layers. The outer one - # raises before it ever runs, so whether it would admit the caller is - # unknown — and the scope behind it must not be disclosed. It returns - # True here to show the walk stops regardless of what the verdict - # would have been. - def tenant_check(ctx: AuthContext) -> bool: - return True - - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=require_scopes("admin")), - AuthMiddleware(auth=tenant_check), - AuthMiddleware(auth=require_scopes("write")), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=[])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["admin"] - - async def test_chain_shortfall_spans_consecutive_scope_only_layers(self): - # The same chain without the opaque layer aggregates all of it, proving - # the reachability bound does not over-correct. - mcp = FastMCP( - middleware=[ - AuthMiddleware(auth=require_scopes("admin")), - AuthMiddleware(auth=require_scopes("write")), - AuthMiddleware(auth=require_scopes("delete")), - ] - ) - - @mcp.tool - def api_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=[])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("api_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["admin", "delete", "write"] - - async def test_restrict_tag_shortfall_names_scope(self): - mcp = make_restricted_tag_server() - - @mcp.tool(tags={"admin"}) - def admin_tool() -> str: - return "ok" - - tok = set_token(make_token(scopes=["read"])) - try: - with pytest.raises(InsufficientScopeError) as exc_info: - await mcp.call_tool("admin_tool", {}) - finally: - auth_context_var.reset(tok) - - assert exc_info.value.required_scopes == ["admin"] diff --git a/tests/server/auth/test_cimd_validators.py b/tests/server/auth/test_cimd_validators.py index f92503f0d..995854ba2 100644 --- a/tests/server/auth/test_cimd_validators.py +++ b/tests/server/auth/test_cimd_validators.py @@ -27,9 +27,11 @@ class TestCIMDAssertionValidator: return CIMDAssertionValidator() @pytest.fixture - def key_pair(self, rsa_key_pair): + def key_pair(self): """Generate RSA key pair for testing.""" - return rsa_key_pair + from fastmcp.server.auth.providers.jwt import RSAKeyPair + + return RSAKeyPair.generate() @pytest.fixture def jwks(self, key_pair): diff --git a/tests/server/auth/test_enhanced_error_responses.py b/tests/server/auth/test_enhanced_error_responses.py index aba2a5870..4d7a2ac34 100644 --- a/tests/server/auth/test_enhanced_error_responses.py +++ b/tests/server/auth/test_enhanced_error_responses.py @@ -7,94 +7,30 @@ This test suite covers: 4. Server branding in error pages """ -import asyncio -from urllib.parse import parse_qs, quote, urlparse - import pytest -from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import OAuthClientInformationFull -from pydantic import AnyHttpUrl, AnyUrl +from pydantic import AnyUrl from starlette.applications import Starlette from starlette.testclient import TestClient from fastmcp import FastMCP -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.http import create_streamable_http_app - - -class _UnderScopedTokenVerifier(TokenVerifier): - def __init__(self, required_scopes: list[str]): - super().__init__(required_scopes=required_scopes) - - async def verify_token(self, token: str) -> AccessToken: - return AccessToken(token=token, client_id="test-client", scopes=["other"]) - - -class _UnderScopedOAuthProxy(OAuthProxy): - async def verify_token(self, token: str) -> AccessToken: - return AccessToken(token=token, client_id="test-client", scopes=["other"]) - - -class _DirectClientRedirectOAuthProxy(OAuthProxy): - """Proxy whose `authorize()` bypasses consent/upstream entirely and - redirects straight back to the client with a `code` — the pattern used - by providers (or tests) that short-circuit the standard - consent -> upstream IdP -> callback flow. OAuthProxy's own `authorize()` - never does this itself, but a subclass legitimately can, and - `AuthorizationHandler.handle()` must still attach `iss` to whatever - redirect comes back. - - Appends its own `code`/`state` with `&` rather than an unconditional - `?` so this still produces a well-formed URL when `redirect_uri` is a - registered redirect that already carries its own query string (e.g. a - client-supplied `iss`).""" - - async def authorize(self, client, params): # type: ignore[override] - separator = "&" if "?" in str(params.redirect_uri) else "?" - return ( - f"{params.redirect_uri}{separator}code=test-auth-code&state={params.state}" - ) - - -class _DirectClientRedirectWithIssOAuthProxy(OAuthProxy): - """Like `_DirectClientRedirectOAuthProxy`, but the provider's - `authorize()` override already put its own `iss` on the redirect — - simulating a provider that is itself RFC 9207-aware (or, when - `redirect_iss` doesn't match this server's issuer, a provider bug). - `response_kind` selects whether the redirect looks like a success - (`code`) or error (`error`) response; `AuthorizationHandler.handle()` - must not duplicate `iss` on either.""" - - def __init__( - self, - *args, - redirect_iss: str, - response_kind: str = "code", - **kwargs, - ): - super().__init__(*args, **kwargs) - self._redirect_iss = redirect_iss - self._response_kind = response_kind - - async def authorize(self, client, params): # type: ignore[override] - payload = ( - f"code=test-auth-code&state={params.state}" - if self._response_kind == "code" - else f"error=access_denied&state={params.state}" - ) - iss = quote(self._redirect_iss, safe="") - return f"{params.redirect_uri}?{payload}&iss={iss}" +from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair class TestEnhancedAuthorizationHandler: """Tests for enhanced authorization handler error responses.""" + @pytest.fixture + def rsa_key_pair(self) -> RSAKeyPair: + """Generate RSA key pair for testing.""" + return RSAKeyPair.generate() + @pytest.fixture def oauth_proxy(self, rsa_key_pair): """Create OAuth proxy for testing.""" + from key_value.aio.stores.memory import MemoryStore + return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", @@ -192,6 +128,8 @@ class TestEnhancedAuthorizationHandler: ) # Need to register synchronously + import asyncio + asyncio.run(oauth_proxy.register_client(client_info)) with TestClient(app) as client: @@ -212,388 +150,6 @@ class TestEnhancedAuthorizationHandler: assert response.status_code == 302 assert "/consent" in response.headers["location"] - def test_redirect_error_includes_proxy_issuer(self, oauth_proxy): - """Authorization error redirects should include RFC 9207 issuer.""" - app = Starlette(routes=oauth_proxy.get_routes()) - - client_info = OAuthClientInformationFull( - client_id="valid-client", - client_secret="valid-secret", - redirect_uris=[AnyUrl("http://localhost:12345/callback")], - scope="read", - ) - - asyncio.run(oauth_proxy.register_client(client_info)) - - with TestClient(app) as client: - response = client.get( - "/authorize", - params={ - "client_id": "valid-client", - "redirect_uri": "http://localhost:12345/callback", - "response_type": "code", - "code_challenge": "test-challenge", - "state": "test-state", - "scope": "admin", - }, - headers={"Accept": "text/html"}, - follow_redirects=False, - ) - - assert response.status_code == 302 - query_params = parse_qs(urlparse(response.headers["location"]).query) - assert query_params["error"] == ["invalid_scope"] - assert query_params["state"] == ["test-state"] - assert query_params["iss"] == ["https://myserver.com/"] - - def test_redirect_error_matches_path_base_url_metadata_issuer(self, rsa_key_pair): - """Authorization error redirects should match the metadata issuer exactly.""" - oauth_proxy = OAuthProxy( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id="test-client-id", - upstream_client_secret="test-client-secret", - token_verifier=JWTVerifier( - public_key=rsa_key_pair.public_key, - issuer="https://test.com", - audience="https://test.com", - base_url="https://test.com", - ), - base_url="https://proxy.example.com/oauth", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - ) - app = Starlette(routes=oauth_proxy.get_routes()) - - client_info = OAuthClientInformationFull( - client_id="valid-client", - client_secret="valid-secret", - redirect_uris=[AnyUrl("http://localhost:12345/callback")], - scope="read", - ) - - asyncio.run(oauth_proxy.register_client(client_info)) - - with TestClient(app) as client: - metadata_response = client.get("/.well-known/oauth-authorization-server") - metadata = metadata_response.json() - - response = client.get( - "/authorize", - params={ - "client_id": "valid-client", - "redirect_uri": "http://localhost:12345/callback", - "response_type": "code", - "code_challenge": "test-challenge", - "state": "test-state", - "scope": "admin", - }, - headers={"Accept": "text/html"}, - follow_redirects=False, - ) - - assert metadata["issuer"] == "https://proxy.example.com/oauth" - assert response.status_code == 302 - query_params = parse_qs(urlparse(response.headers["location"]).query) - assert query_params["error"] == ["invalid_scope"] - assert query_params["state"] == ["test-state"] - assert query_params["iss"] == [metadata["issuer"]] - - def test_success_redirect_from_authorize_override_includes_issuer( - self, rsa_key_pair - ): - """RFC 9207 regression: a `code` redirect returned directly by - `authorize()` (bypassing consent/upstream) must carry `iss` too, not - just `error` redirects. - - `AuthorizationHandler.handle()` previously only attached `iss` when - the SDK's redirect contained an `error` parameter. The base - `OAuthProxy.authorize()` never redirects straight to the client, so - this gap was invisible until a provider override (or a test mock, - like the GitHub provider integration test) returned the client - redirect directly — at which point the server was advertising - `authorization_response_iss_parameter_supported: true` while - silently breaking RFC 9207-aware clients on this path. - """ - oauth_proxy = _DirectClientRedirectOAuthProxy( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id="test-client-id", - upstream_client_secret="test-client-secret", - token_verifier=JWTVerifier( - public_key=rsa_key_pair.public_key, - issuer="https://test.com", - audience="https://test.com", - base_url="https://test.com", - ), - base_url="https://myserver.com", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - ) - app = Starlette(routes=oauth_proxy.get_routes()) - - client_info = OAuthClientInformationFull( - client_id="valid-client", - client_secret="valid-secret", - redirect_uris=[AnyUrl("http://localhost:12345/callback")], - scope="read", - ) - asyncio.run(oauth_proxy.register_client(client_info)) - - with TestClient(app) as client: - metadata = client.get("/.well-known/oauth-authorization-server").json() - assert metadata["authorization_response_iss_parameter_supported"] is True - - response = client.get( - "/authorize", - params={ - "client_id": "valid-client", - "redirect_uri": "http://localhost:12345/callback", - "response_type": "code", - "code_challenge": "test-challenge", - "state": "test-state", - }, - follow_redirects=False, - ) - - assert response.status_code == 302 - query_params = parse_qs(urlparse(response.headers["location"]).query) - assert query_params["code"] == ["test-auth-code"] - assert query_params["state"] == ["test-state"] - assert query_params["iss"] == [metadata["issuer"]] - - def test_success_redirect_does_not_duplicate_iss_already_in_redirect_uri( - self, rsa_key_pair - ): - """RFC 9207 P2 regression: a registered redirect_uri may already - carry its own `iss` query parameter — distinct from the provider - adding one itself (covered by - `test_success_redirect_with_matching_iss_not_duplicated` below). - `AuthorizationHandler.handle()` must still land on exactly one - `iss` (the canonical value), with every other query byte on the - registered URI preserved untouched. - """ - oauth_proxy = _DirectClientRedirectOAuthProxy( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id="test-client-id", - upstream_client_secret="test-client-secret", - token_verifier=JWTVerifier( - public_key=rsa_key_pair.public_key, - issuer="https://test.com", - audience="https://test.com", - base_url="https://test.com", - ), - base_url="https://myserver.com", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - ) - app = Starlette(routes=oauth_proxy.get_routes()) - - client_redirect_uri = ( - "http://localhost:12345/callback?iss=tenant&flag&sig=%FF%FE" - ) - client_info = OAuthClientInformationFull( - client_id="valid-client", - client_secret="valid-secret", - redirect_uris=[AnyUrl(client_redirect_uri)], - scope="read", - ) - asyncio.run(oauth_proxy.register_client(client_info)) - - with TestClient(app) as client: - metadata = client.get("/.well-known/oauth-authorization-server").json() - - response = client.get( - "/authorize", - params={ - "client_id": "valid-client", - "redirect_uri": client_redirect_uri, - "response_type": "code", - "code_challenge": "test-challenge", - "state": "test-state", - }, - follow_redirects=False, - ) - - assert response.status_code == 302 - location = response.headers["location"] - query = urlparse(location).query - query_params = parse_qs(query) - assert query_params["code"] == ["test-auth-code"] - assert query_params["state"] == ["test-state"] - # Exactly one `iss`, corrected to the canonical value -- a - # duplicate would make this list have length 2. - assert query_params["iss"] == [metadata["issuer"]] - # Other query bytes from the registered redirect_uri survive - # byte-for-byte. - assert "flag" in query - assert "sig=%FF%FE" in query - - def test_success_redirect_with_matching_iss_not_duplicated(self, rsa_key_pair): - """If a provider's `authorize()` override already stamped the - correct `iss` on its redirect, `handle()` must not append a second - one — RFC 6749 §3.1 forbids a response parameter appearing twice. - """ - oauth_proxy = _DirectClientRedirectWithIssOAuthProxy( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id="test-client-id", - upstream_client_secret="test-client-secret", - token_verifier=JWTVerifier( - public_key=rsa_key_pair.public_key, - issuer="https://test.com", - audience="https://test.com", - base_url="https://test.com", - ), - base_url="https://myserver.com", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - redirect_iss="https://myserver.com/", - ) - app = Starlette(routes=oauth_proxy.get_routes()) - - client_info = OAuthClientInformationFull( - client_id="valid-client", - client_secret="valid-secret", - redirect_uris=[AnyUrl("http://localhost:12345/callback")], - scope="read", - ) - asyncio.run(oauth_proxy.register_client(client_info)) - - with TestClient(app) as client: - metadata = client.get("/.well-known/oauth-authorization-server").json() - - response = client.get( - "/authorize", - params={ - "client_id": "valid-client", - "redirect_uri": "http://localhost:12345/callback", - "response_type": "code", - "code_challenge": "test-challenge", - "state": "test-state", - }, - follow_redirects=False, - ) - - assert response.status_code == 302 - query_params = parse_qs(urlparse(response.headers["location"]).query) - # Exactly one `iss` (a duplicate would make this list have length 2). - assert query_params["iss"] == [metadata["issuer"]] - - def test_success_redirect_with_mismatched_iss_is_corrected(self, rsa_key_pair): - """A provider's `authorize()` override can put an `iss` on its - redirect that doesn't match what this server advertises in its own - discovery document (`self._issuer`). An RFC 9207 client validates - `iss` against that document, so the mismatched value is already - unusable to a spec-compliant client. `handle()` corrects it to the - canonical value rather than leaving the broken value in place or - appending a second `iss` (which RFC 6749 §3.1 forbids outright). - - This is a deliberate policy choice, not the only defensible one — - see the comment in `AuthorizationHandler.handle()` for the - reasoning, and update this test if that policy changes. - """ - oauth_proxy = _DirectClientRedirectWithIssOAuthProxy( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id="test-client-id", - upstream_client_secret="test-client-secret", - token_verifier=JWTVerifier( - public_key=rsa_key_pair.public_key, - issuer="https://test.com", - audience="https://test.com", - base_url="https://test.com", - ), - base_url="https://myserver.com", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - redirect_iss="https://wrong-issuer.example.com/", - ) - app = Starlette(routes=oauth_proxy.get_routes()) - - client_info = OAuthClientInformationFull( - client_id="valid-client", - client_secret="valid-secret", - redirect_uris=[AnyUrl("http://localhost:12345/callback")], - scope="read", - ) - asyncio.run(oauth_proxy.register_client(client_info)) - - with TestClient(app) as client: - metadata = client.get("/.well-known/oauth-authorization-server").json() - - response = client.get( - "/authorize", - params={ - "client_id": "valid-client", - "redirect_uri": "http://localhost:12345/callback", - "response_type": "code", - "code_challenge": "test-challenge", - "state": "test-state", - }, - follow_redirects=False, - ) - - assert response.status_code == 302 - query_params = parse_qs(urlparse(response.headers["location"]).query) - # Exactly one `iss`, corrected to the canonical value rather than - # left mismatched or duplicated. - assert query_params["iss"] == [metadata["issuer"]] - assert query_params["iss"] != ["https://wrong-issuer.example.com/"] - - def test_error_redirect_with_existing_iss_not_duplicated(self, rsa_key_pair): - """The duplication guard applies to error redirects too, not just - success ones — a provider override can construct an `error` - redirect that already carries `iss`. - """ - oauth_proxy = _DirectClientRedirectWithIssOAuthProxy( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id="test-client-id", - upstream_client_secret="test-client-secret", - token_verifier=JWTVerifier( - public_key=rsa_key_pair.public_key, - issuer="https://test.com", - audience="https://test.com", - base_url="https://test.com", - ), - base_url="https://myserver.com", - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - redirect_iss="https://myserver.com/", - response_kind="error", - ) - app = Starlette(routes=oauth_proxy.get_routes()) - - client_info = OAuthClientInformationFull( - client_id="valid-client", - client_secret="valid-secret", - redirect_uris=[AnyUrl("http://localhost:12345/callback")], - scope="read", - ) - asyncio.run(oauth_proxy.register_client(client_info)) - - with TestClient(app) as client: - metadata = client.get("/.well-known/oauth-authorization-server").json() - - response = client.get( - "/authorize", - params={ - "client_id": "valid-client", - "redirect_uri": "http://localhost:12345/callback", - "response_type": "code", - "code_challenge": "test-challenge", - "state": "test-state", - }, - follow_redirects=False, - ) - - assert response.status_code == 302 - query_params = parse_qs(urlparse(response.headers["location"]).query) - assert query_params["error"] == ["access_denied"] - assert query_params["iss"] == [metadata["issuer"]] - def test_html_error_includes_server_branding(self, oauth_proxy): """Test that HTML error page includes server branding from FastMCP instance.""" from mcp_types import Icon @@ -631,37 +187,10 @@ class TestEnhancedAuthorizationHandler: class TestEnhancedRequireAuthMiddleware: """Tests for enhanced authentication middleware error messages.""" - @staticmethod - def create_scoped_app( - required_scopes: list[str], - scopes_supported: list[str], - challenge_scopes: list[str] | None = None, - ) -> Starlette: - auth = RemoteAuthProvider( - token_verifier=_UnderScopedTokenVerifier(required_scopes), - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - base_url="http://localhost:8000", - scopes_supported=scopes_supported, - challenge_scopes=challenge_scopes, - ) - return FastMCP("Test Server", auth=auth).http_app() - - @staticmethod - def create_oauth_app() -> Starlette: - from key_value.aio.stores.memory import MemoryStore - - auth = _UnderScopedOAuthProxy( - upstream_authorization_endpoint="https://auth.example.com/authorize", - upstream_token_endpoint="https://auth.example.com/token", - upstream_client_id="test-client-id", - upstream_client_secret="test-client-secret", - token_verifier=_UnderScopedTokenVerifier(["openid"]), - base_url="http://localhost:8000", - valid_scopes=["openid", "email", "calendar"], - jwt_signing_key="test-secret", - client_storage=MemoryStore(), - ) - return FastMCP("Test Server", auth=auth).http_app() + @pytest.fixture + def rsa_key_pair(self) -> RSAKeyPair: + """Generate RSA key pair for testing.""" + return RSAKeyPair.generate() @pytest.fixture def jwt_verifier(self, rsa_key_pair): @@ -675,6 +204,8 @@ class TestEnhancedRequireAuthMiddleware: def test_missing_auth_no_error_attribute(self, jwt_verifier): """Test that missing auth returns 401 without error attribute (RFC 6750 §3.1).""" + from fastmcp.server.http import create_streamable_http_app + server = FastMCP("Test Server") @server.tool @@ -699,110 +230,10 @@ class TestEnhancedRequireAuthMiddleware: assert "error=" not in www_auth assert response.content == b"" - def test_missing_auth_challenge_includes_supported_scopes(self): - app = self.create_scoped_app( - required_scopes=["read"], - scopes_supported=["api://client-id/read"], - challenge_scopes=["api://client-id/read"], - ) - - with TestClient(app) as client: - response = client.post("/mcp") - - assert response.status_code == 401 - assert response.headers["www-authenticate"] == ( - 'Bearer scope="api://client-id/read", ' - 'resource_metadata="http://localhost:8000/' - '.well-known/oauth-protected-resource/mcp"' - ) - - def test_insufficient_scope_challenge_includes_supported_scopes(self): - app = self.create_scoped_app( - required_scopes=["read"], - scopes_supported=["api://client-id/read"], - challenge_scopes=["api://client-id/read"], - ) - - with TestClient(app) as client: - response = client.post("/mcp", headers={"Authorization": "Bearer narrow"}) - - assert response.status_code == 403 - assert response.headers["www-authenticate"] == ( - 'Bearer error="insufficient_scope", ' - 'error_description="Required scope: read", ' - 'scope="api://client-id/read", ' - 'resource_metadata="http://localhost:8000/' - '.well-known/oauth-protected-resource/mcp"' - ) - - def test_missing_auth_challenge_uses_required_scope_with_empty_catalog(self): - app = self.create_scoped_app(required_scopes=["read"], scopes_supported=[]) - - with TestClient(app) as client: - response = client.post("/mcp") - - assert response.status_code == 401 - assert response.headers["www-authenticate"] == ( - 'Bearer scope="read", resource_metadata="http://localhost:8000/' - '.well-known/oauth-protected-resource/mcp"' - ) - - def test_remote_missing_auth_challenge_excludes_optional_catalog_scopes(self): - app = self.create_scoped_app( - required_scopes=["read"], - scopes_supported=["read", "admin"], - ) - - with TestClient(app) as client: - response = client.post("/mcp") - metadata = client.get("/.well-known/oauth-protected-resource/mcp").json() - - assert response.status_code == 401 - assert 'scope="read"' in response.headers["www-authenticate"] - assert "admin" not in response.headers["www-authenticate"] - assert metadata["scopes_supported"] == ["read", "admin"] - - def test_remote_insufficient_scope_challenge_excludes_optional_catalog_scopes( - self, - ): - app = self.create_scoped_app( - required_scopes=["read"], - scopes_supported=["read", "admin"], - ) - - with TestClient(app) as client: - response = client.post("/mcp", headers={"Authorization": "Bearer narrow"}) - - assert response.status_code == 403 - assert 'scope="read"' in response.headers["www-authenticate"] - assert "admin" not in response.headers["www-authenticate"] - - def test_oauth_missing_auth_challenge_excludes_optional_scopes(self): - app = self.create_oauth_app() - - with TestClient(app) as client: - response = client.post("/mcp") - metadata = client.get("/.well-known/oauth-protected-resource/mcp").json() - - assert response.status_code == 401 - assert 'scope="openid"' in response.headers["www-authenticate"] - assert "email" not in response.headers["www-authenticate"] - assert "calendar" not in response.headers["www-authenticate"] - assert metadata["scopes_supported"] == ["openid", "email", "calendar"] - - def test_oauth_insufficient_scope_challenge_excludes_optional_scopes(self): - app = self.create_oauth_app() - - with TestClient(app) as client: - response = client.post("/mcp", headers={"Authorization": "Bearer narrow"}) - - assert response.status_code == 403 - assert 'scope="openid"' in response.headers["www-authenticate"] - assert "email" not in response.headers["www-authenticate"] - assert "calendar" not in response.headers["www-authenticate"] - def test_invalid_token_enhanced_error_message(self, jwt_verifier): """Test that invalid_token errors have enhanced error messages.""" + from fastmcp.server.http import create_streamable_http_app + server = FastMCP("Test Server") @server.tool @@ -833,6 +264,8 @@ class TestEnhancedRequireAuthMiddleware: def test_invalid_token_www_authenticate_header_format(self, jwt_verifier): """Test that invalid token WWW-Authenticate header includes error attribute.""" + from fastmcp.server.http import create_streamable_http_app + server = FastMCP("Test Server") app = create_streamable_http_app( server=server, @@ -857,6 +290,8 @@ class TestEnhancedRequireAuthMiddleware: def test_insufficient_scope_not_enhanced(self, rsa_key_pair): """Test that insufficient_scope errors are not modified.""" # Create a valid token with wrong scopes + from fastmcp.server.http import create_streamable_http_app + jwt_verifier = JWTVerifier( public_key=rsa_key_pair.public_key, issuer="https://test.com", @@ -889,15 +324,17 @@ class TestContentNegotiation: """Tests for content negotiation in error responses.""" @pytest.fixture - def oauth_proxy(self, rsa_key_pair): + def oauth_proxy(self): """Create OAuth proxy for testing.""" + from key_value.aio.stores.memory import MemoryStore + return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="test-client-id", upstream_client_secret="test-client-secret", token_verifier=JWTVerifier( - public_key=rsa_key_pair.public_key, + public_key=RSAKeyPair.generate().public_key, issuer="https://test.com", audience="https://test.com", base_url="https://test.com", diff --git a/tests/server/auth/test_issuer_url_identity.py b/tests/server/auth/test_issuer_url_identity.py deleted file mode 100644 index 8a27374f8..000000000 --- a/tests/server/auth/test_issuer_url_identity.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Tests that `issuer_url` is authoritative for authorization server identity. - -Regression tests for #4610. `issuer_url` lets the OAuth issuer identity differ -from `base_url`, which is where the OAuth endpoints are actually mounted. The -issuer identity — the `issuer` field of the authorization server metadata, the -`iss` claim of minted tokens, and the RFC 9207 `iss` authorization response -parameter — must come from `issuer_url`, while every endpoint URL must keep -coming from `base_url`. - -RFC 8414 §3.3 is the reason this matters: the protected resource metadata points -clients at `issuer_url`, the client performs discovery there, and the `issuer` -in the returned metadata must match the identifier used for discovery. -""" - -import re -import time -from urllib.parse import parse_qs, urlparse - -import httpx2 -import pytest -from key_value.aio.stores.memory import MemoryStore -from mcp.server.auth.provider import AuthorizationParams -from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions -from mcp.shared.auth import OAuthClientInformationFull -from pydantic import AnyUrl -from starlette.applications import Starlette -from starlette.routing import Mount -from starlette.testclient import TestClient - -from fastmcp import FastMCP -from fastmcp.server.auth.auth import AccessToken, TokenVerifier -from fastmcp.server.auth.identity_assertion import IdentityAssertion -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider - -# The server is mounted under /api, so its endpoints live at BASE_URL while its -# issuer identity is the root of the same host. -BASE_URL = "https://api.example.com/api" -ISSUER_URL = "https://api.example.com" - -# Pydantic renders a bare-authority AnyHttpUrl with a trailing slash. -ISSUER = "https://api.example.com/" -BASE_URL_ISSUER = "https://api.example.com/api" - - -class _Verifier(TokenVerifier): - """Minimal token verifier.""" - - def __init__(self): - self.required_scopes = ["read"] - - async def verify_token(self, token: str) -> AccessToken: - return AccessToken( - token=token, - client_id="client-id", - scopes=self.required_scopes, - expires_at=int(time.time() + 3600), - ) - - -def build_proxy(issuer_url: str | None) -> OAuthProxy: - """Build an OAuth proxy mounted at BASE_URL, optionally with a distinct issuer.""" - return OAuthProxy( - upstream_authorization_endpoint="https://upstream.example.com/authorize", - upstream_token_endpoint="https://upstream.example.com/token", - upstream_revocation_endpoint="https://upstream.example.com/revoke", - upstream_client_id="client-id", - upstream_client_secret="client-secret", - token_verifier=_Verifier(), - base_url=BASE_URL, - issuer_url=issuer_url, - client_storage=MemoryStore(), - jwt_signing_key="test-secret", - ) - - -def build_provider(issuer_url: str | None) -> InMemoryOAuthProvider: - """Build a plain OAuth provider mounted at BASE_URL, optionally with a distinct issuer.""" - return InMemoryOAuthProvider( - base_url=BASE_URL, - issuer_url=issuer_url, - client_registration_options=ClientRegistrationOptions(enabled=True), - revocation_options=RevocationOptions(enabled=True), - ) - - -def build_mounted_app(auth_provider) -> Starlette: - """Mount an authenticated FastMCP server under /api with well-known routes at root.""" - mcp = FastMCP("test-server", auth=auth_provider) - mcp_app = mcp.http_app(path="/mcp") - return Starlette( - routes=[ - *auth_provider.get_well_known_routes(mcp_path="/mcp"), - Mount("/api", app=mcp_app), - ], - lifespan=mcp_app.lifespan, - ) - - -async def fetch_json(auth_provider, path: str) -> dict: - """Fetch a well-known document from a mounted authenticated server.""" - async with httpx2.AsyncClient( - transport=httpx2.ASGITransport(app=build_mounted_app(auth_provider)), - base_url=ISSUER_URL, - ) as client: - response = await client.get(path) - assert response.status_code == 200 - return response.json() - - -class TestOAuthProxyIssuerIdentity: - """`OAuthProxy` (and therefore `OIDCProxy`) identity comes from `issuer_url`.""" - - async def test_protected_resource_metadata_points_at_issuer_url(self): - metadata = await fetch_json( - build_proxy(ISSUER_URL), "/.well-known/oauth-protected-resource/api/mcp" - ) - assert metadata["authorization_servers"] == [ISSUER] - - async def test_authorization_server_metadata_issuer_is_issuer_url(self): - metadata = await fetch_json( - build_proxy(ISSUER_URL), "/.well-known/oauth-authorization-server" - ) - assert metadata["issuer"] == ISSUER - - @pytest.mark.parametrize( - "field, expected", - [ - ("authorization_endpoint", f"{BASE_URL}/authorize"), - ("token_endpoint", f"{BASE_URL}/token"), - ("registration_endpoint", f"{BASE_URL}/register"), - ("revocation_endpoint", f"{BASE_URL}/revoke"), - ], - ) - async def test_endpoints_stay_on_base_url(self, field: str, expected: str): - metadata = await fetch_json( - build_proxy(ISSUER_URL), "/.well-known/oauth-authorization-server" - ) - assert metadata[field] == expected - - async def test_minted_token_iss_claim_is_issuer_url(self): - proxy = build_proxy(ISSUER_URL) - # get_routes() configures the MCP path, which creates the JWT issuer. - proxy.get_routes(mcp_path="/mcp") - - token = proxy.jwt_issuer.issue_access_token( - client_id="client-id", scopes=["read"], jti="test-jti" - ) - - assert proxy.jwt_issuer.verify_token(token)["iss"] == ISSUER - - async def test_authorization_response_iss_matches_metadata_issuer(self): - """RFC 9207: the `iss` on a client-facing response matches the metadata.""" - proxy = build_proxy(ISSUER_URL) - redirect = "http://localhost:5100/callback" - client = OAuthClientInformationFull( - client_id="rfc9207-client", - client_secret="s", - redirect_uris=[AnyUrl(redirect)], - ) - await proxy.register_client(client) - consent_url = await proxy.authorize( - client, - AuthorizationParams( - redirect_uri=AnyUrl(redirect), - redirect_uri_provided_explicitly=True, - state="client-state", - code_challenge="challenge", - scopes=["read"], - ), - ) - txn_id = parse_qs(urlparse(consent_url).query)["txn_id"][0] - - app = Starlette(routes=proxy.get_routes()) - with TestClient(app) as test_client: - metadata = test_client.get("/.well-known/oauth-authorization-server").json() - - consent = test_client.get(f"/consent?txn_id={txn_id}") - csrf_match = re.search( - r"name=\"csrf_token\"\s+value=\"([^\"]+)\"", consent.text - ) - assert csrf_match - for name, value in consent.cookies.items(): - test_client.cookies.set(name, value) - - denial = test_client.post( - "/consent", - data={ - "action": "deny", - "txn_id": txn_id, - "csrf_token": csrf_match.group(1), - }, - follow_redirects=False, - ) - - assert denial.status_code in (302, 303) - params = parse_qs(urlparse(denial.headers["location"]).query) - assert params["iss"] == [ISSUER] - assert params["iss"] == [metadata["issuer"]] - - @pytest.mark.parametrize( - "issuer_url, expected", - [(ISSUER_URL, ISSUER), (None, BASE_URL_ISSUER)], - ) - def test_identity_assertion_audience_is_issuer_identifier( - self, issuer_url: str | None, expected: str - ): - """SEP-990: an ID-JAG is bound to the server's advertised issuer. - - RFC 7523 §3 requires the `aud` to identify the authorization server, - and an authorization server is identified by its issuer — the value - published as `issuer` in the authorization server metadata. - """ - proxy = OAuthProxy( - upstream_authorization_endpoint="https://upstream.example.com/authorize", - upstream_token_endpoint="https://upstream.example.com/token", - upstream_client_id="client-id", - upstream_client_secret="client-secret", - token_verifier=_Verifier(), - base_url=BASE_URL, - issuer_url=issuer_url, - client_storage=MemoryStore(), - jwt_signing_key="test-secret", - identity_assertion=IdentityAssertion( - trusted_issuers=["https://login.example.com"] - ), - ) - - validator = proxy._identity_assertion_validator - assert validator is not None - assert validator.audience == [expected.rstrip("/"), f"{expected.rstrip('/')}/"] - - -class TestOAuthProxyIssuerDefaults: - """With `issuer_url` unset, identity falls back to `base_url` as before.""" - - async def test_authorization_server_metadata_issuer_is_base_url(self): - # base_url has a path, so RFC 8414 path-aware discovery applies. - metadata = await fetch_json( - build_proxy(None), "/.well-known/oauth-authorization-server/api" - ) - assert metadata["issuer"] == BASE_URL_ISSUER - - async def test_protected_resource_metadata_points_at_base_url(self): - metadata = await fetch_json( - build_proxy(None), "/.well-known/oauth-protected-resource/api/mcp" - ) - assert metadata["authorization_servers"] == [BASE_URL_ISSUER] - - async def test_minted_token_iss_claim_is_base_url(self): - proxy = build_proxy(None) - proxy.get_routes(mcp_path="/mcp") - - token = proxy.jwt_issuer.issue_access_token( - client_id="client-id", scopes=["read"], jti="test-jti" - ) - - assert proxy.jwt_issuer.verify_token(token)["iss"] == BASE_URL_ISSUER - - -class TestOAuthProviderIssuerIdentity: - """The plain `OAuthProvider` path behaves the same way.""" - - async def test_authorization_server_metadata_issuer_is_issuer_url(self): - metadata = await fetch_json( - build_provider(ISSUER_URL), "/.well-known/oauth-authorization-server" - ) - assert metadata["issuer"] == ISSUER - - async def test_protected_resource_metadata_points_at_issuer_url(self): - metadata = await fetch_json( - build_provider(ISSUER_URL), - "/.well-known/oauth-protected-resource/api/mcp", - ) - assert metadata["authorization_servers"] == [ISSUER] - - @pytest.mark.parametrize( - "field, expected", - [ - ("authorization_endpoint", f"{BASE_URL}/authorize"), - ("token_endpoint", f"{BASE_URL}/token"), - ("registration_endpoint", f"{BASE_URL}/register"), - ("revocation_endpoint", f"{BASE_URL}/revoke"), - ], - ) - async def test_endpoints_stay_on_base_url(self, field: str, expected: str): - metadata = await fetch_json( - build_provider(ISSUER_URL), "/.well-known/oauth-authorization-server" - ) - assert metadata[field] == expected - - async def test_issuer_defaults_to_base_url(self): - # base_url has a path, so RFC 8414 path-aware discovery applies. - metadata = await fetch_json( - build_provider(None), "/.well-known/oauth-authorization-server/api" - ) - assert metadata["issuer"] == BASE_URL_ISSUER diff --git a/tests/server/auth/test_jwt_issuer.py b/tests/server/auth/test_jwt_issuer.py index f34d96b9c..1f1b8d3ea 100644 --- a/tests/server/auth/test_jwt_issuer.py +++ b/tests/server/auth/test_jwt_issuer.py @@ -1,6 +1,7 @@ """Unit tests for JWT issuer and token encryption.""" import base64 +import time import pytest from joserfc.errors import JoseError @@ -162,26 +163,24 @@ class TestJWTIssuer: def test_verify_token_validates_expiration(self, issuer): """Test that expired tokens are rejected.""" - # A token that is still valid should verify successfully. - valid_token = issuer.issue_access_token( + # Create token that expires in 1 second + token = issuer.issue_access_token( client_id="client-abc", scopes=["read"], - jti="valid-token-id", + jti="token-id", + expires_in=1, ) - payload = issuer.verify_token(valid_token) + + # Should be valid immediately + payload = issuer.verify_token(token) assert payload["client_id"] == "client-abc" - # A token issued already-expired should be rejected. verify_token() - # does a strict `exp < time.time()` comparison with no clock-skew - # leeway, so this is instant and deterministic (no sleep needed). - expired_token = issuer.issue_access_token( - client_id="client-abc", - scopes=["read"], - jti="expired-token-id", - expires_in=-10, - ) + # Wait for token to expire + time.sleep(1.1) + + # Should be rejected with pytest.raises(JoseError, match="expired"): - issuer.verify_token(expired_token) + issuer.verify_token(token) def test_verify_token_validates_issuer(self, issuer): """Test that tokens from different issuers are rejected.""" diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 20007f3e5..fb182dda9 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -1,26 +1,16 @@ import time from collections.abc import AsyncGenerator from typing import Any, cast -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from joserfc import jwk as jose_jwk from joserfc import jwt from joserfc.jws import JWSRegistry from joserfc.registry import HeaderParameter -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from starlette.requests import Request from fastmcp import FastMCP from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair -from fastmcp.server.dependencies import ( - FastMCPRequestContext, - fastmcp_request_ctx, - get_access_token, -) from fastmcp.utilities.tests import run_server_async from tests.utilities.httpx2_mock import HTTPXMock @@ -84,47 +74,9 @@ class SymmetricKeyHelper: return token -def create_okp_key_pair( - private_key: Ed25519PrivateKey | Ed448PrivateKey, -) -> tuple[str, str]: - """Serialize an EdDSA key pair as PEM strings.""" - private_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode() - public_pem = ( - private_key.public_key() - .public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - .decode() - ) - return private_pem, public_pem - - -def create_okp_token( - private_key: str, - algorithm: str, - *, - kid: str | None = None, -) -> str: - """Create a JWT signed by an OKP key.""" - header = {"alg": algorithm} - if kid is not None: - header["kid"] = kid - return jwt.encode( - header, - { - "sub": "test-user", - "iss": "https://test.example.com", - "aud": "https://api.example.com", - "exp": int(time.time()) + 3600, - }, - jose_jwk.import_key(private_key, "OKP"), - algorithms=[algorithm], - ) +@pytest.fixture(scope="module") +def rsa_key_pair() -> RSAKeyPair: + return RSAKeyPair.generate() @pytest.fixture(scope="module") @@ -385,7 +337,6 @@ class TestSymmetricKeyJWT: assert "read" in access_token.scopes assert "write" in access_token.scopes assert access_token.expires_at is not None - assert access_token.subject == "test-user" async def test_symmetric_token_with_different_algorithms( self, symmetric_key_helper: SymmetricKeyHelper @@ -531,158 +482,6 @@ class TestSymmetricKeyJWT: assert access_token is None -class TestEdDSAJWT: - """Tests for JWT verification using Edwards-curve keys.""" - - @pytest.mark.parametrize("algorithm", ["Ed25519", "Ed448"]) - async def test_static_public_key(self, algorithm: str): - """Fully specified EdDSA algorithms verify with a static public key.""" - if algorithm == "Ed25519": - private_key = Ed25519PrivateKey.generate() - else: - private_key = Ed448PrivateKey.generate() - private_pem, public_pem = create_okp_key_pair(private_key) - verifier = JWTVerifier( - public_key=public_pem, - issuer="https://test.example.com", - audience="https://api.example.com", - algorithm=algorithm, - ) - - access_token = await verifier.load_access_token( - create_okp_token(private_pem, algorithm) - ) - - assert access_token is not None - assert access_token.client_id == "test-user" - - @pytest.mark.filterwarnings( - "ignore:EdDSA is deprecated via RFC 9864:joserfc.errors.SecurityWarning" - ) - async def test_legacy_eddsa_jwks( - self, - httpx_mock: HTTPXMock, - ): - """Legacy EdDSA tokens verify against an Ed25519 JWKS entry.""" - private_pem, public_pem = create_okp_key_pair(Ed25519PrivateKey.generate()) - public_jwk = jose_jwk.import_key(public_pem, "OKP").as_dict() - public_jwk.update(kid="ed25519-key", alg="EdDSA", use="sig") - httpx_mock.add_response(json={"keys": [public_jwk]}) - verifier = JWTVerifier( - jwks_uri="https://test.example.com/.well-known/jwks.json", - issuer="https://test.example.com", - audience="https://api.example.com", - algorithm="EdDSA", - ) - - access_token = await verifier.load_access_token( - create_okp_token(private_pem, "EdDSA", kid="ed25519-key") - ) - - assert access_token is not None - assert access_token.client_id == "test-user" - - -def _create_token_without_sub( - rsa_key_pair: RSAKeyPair, - *, - issuer: str = "https://test.example.com", - audience: str | None = None, -) -> str: - """Sign a JWT with no 'sub' claim, to exercise the missing-subject path.""" - payload: dict[str, str | int] = { - "iss": issuer, - "iat": int(time.time()), - "exp": int(time.time()) + 3600, - } - if audience: - payload["aud"] = audience - signing_key = jose_jwk.import_key( - rsa_key_pair.private_key.get_secret_value(), "RSA" - ) - return jwt.encode({"alg": "RS256"}, payload, signing_key, algorithms=["RS256"]) - - -class TestJWTAccessTokenSubject: - """Regression tests for issue #4266. - - ``get_access_token().subject`` was always ``None``, even when the - authorization server supplied a ``sub`` claim, because neither the - JWT verifier nor the dependency-layer conversion carried it through. - """ - - async def test_subject_populated_from_sub_claim( - self, bearer_provider: JWTVerifier, rsa_key_pair: RSAKeyPair - ): - """A JWT bearing a 'sub' claim results in a populated subject.""" - token = rsa_key_pair.create_token( - subject="user-42", - issuer="https://test.example.com", - audience="https://api.example.com", - ) - - access_token = await bearer_provider.load_access_token(token) - - assert access_token is not None - assert access_token.subject == "user-42" - - async def test_subject_is_none_without_sub_claim( - self, bearer_provider: JWTVerifier, rsa_key_pair: RSAKeyPair - ): - """A JWT with no 'sub' claim yields subject=None instead of crashing.""" - token = _create_token_without_sub( - rsa_key_pair, audience="https://api.example.com" - ) - - access_token = await bearer_provider.load_access_token(token) - - assert access_token is not None - assert access_token.subject is None - - async def test_get_access_token_returns_subject_for_realistic_jwt_request( - self, bearer_provider: JWTVerifier, rsa_key_pair: RSAKeyPair - ): - """End-to-end path a real authenticated request actually takes. - - Mirrors ``mcp.server.auth.middleware.bearer_auth.BearerAuthBackend``, - which wraps whatever the ``TokenVerifier`` returns directly into - ``AuthenticatedUser`` and stores it on ``request.scope["user"]``. - ``get_access_token()`` reads that object first, before ever reaching - the dependency-layer conversion block - so this is the path that - must carry ``subject`` through for real JWT-authenticated requests. - """ - token = rsa_key_pair.create_token( - subject="user-42", - issuer="https://test.example.com", - audience="https://api.example.com", - ) - access_token = await bearer_provider.load_access_token(token) - assert access_token is not None - - user = AuthenticatedUser(access_token) - request = Request({"type": "http", "user": user, "auth": MagicMock()}) - - ctx_token = fastmcp_request_ctx.set( - FastMCPRequestContext( - session=MagicMock(), - request_id="0", - meta=None, - request=request, - protocol_version="2025-06-18", - close_sse_stream=None, - lifespan_context=MagicMock(), - _srctx=MagicMock(meta=None), - ) - ) - try: - result = get_access_token() - finally: - fastmcp_request_ctx.reset(ctx_token) - - assert result is not None - assert result.subject == "user-42" - - class TestBearerTokenJWKS: """Tests for JWKS URI functionality. @@ -760,7 +559,7 @@ class TestBearerTokenJWKS: assert access_token.claims.get("iss") == issuer assert access_token.claims.get("aud") == audience - async def test_jwks_skips_unusable_keys( + async def test_jwks_skips_unsupported_key_types( self, rsa_key_pair: RSAKeyPair, jwks_provider: JWTVerifier, @@ -768,19 +567,28 @@ class TestBearerTokenJWKS: httpx_mock: HTTPXMock, mock_dns, ): - """An unusable key must not poison the whole key set - #4515.""" - malformed_key = cast( + """An unsupported key type in the JWKS (e.g. OKP/Ed25519) must be + skipped, not poison the whole key set - #4515. + + Some authorization servers (e.g. Rauthy, Ory Hydra) publish an + Ed25519 key alongside RSA keys; tokens signed by the RSA keys must + still verify. + """ + okp_key = cast( "JWKData", { - "kty": "RSA", - "kid": "malformed-key", + "kty": "OKP", + "crv": "Ed25519", + "kid": "ed25519-key", + "alg": "EdDSA", "use": "sig", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", }, ) mock_jwks_data["keys"][0]["kid"] = "test-key-1" - # Malformed key FIRST, so an unguarded conversion loop would + # Unsupported key FIRST, so an unguarded conversion loop would # abort before reaching the RSA key the token needs - mock_jwks_data["keys"].insert(0, malformed_key) + mock_jwks_data["keys"].insert(0, okp_key) httpx_mock.add_response(json=mock_jwks_data) token = rsa_key_pair.create_token( @@ -794,60 +602,37 @@ class TestBearerTokenJWKS: assert access_token is not None assert access_token.client_id == "test-user" - async def test_jwks_ignores_other_algorithm_key_types_without_kid( - self, - rsa_key_pair: RSAKeyPair, - jwks_provider: JWTVerifier, - mock_jwks_data: JWKSData, - httpx_mock: HTTPXMock, - mock_dns, - ): - """Unrelated key types do not make a no-kid lookup ambiguous.""" - _, public_pem = create_okp_key_pair(Ed25519PrivateKey.generate()) - okp_key = jose_jwk.import_key(public_pem, "OKP").as_dict() - okp_key.update(kid="ed25519-key", alg="Ed25519", use="sig") - mock_jwks_data["keys"].append(cast("JWKData", okp_key)) - httpx_mock.add_response(json=mock_jwks_data) - - token = rsa_key_pair.create_token( - subject="test-user", - issuer="https://test.example.com", - audience="https://api.example.com", - ) - - access_token = await jwks_provider.load_access_token(token) - - assert access_token is not None - assert access_token.client_id == "test-user" - - async def test_jwks_with_only_unusable_keys_rejects_cleanly( + async def test_jwks_with_only_unsupported_keys_rejects_cleanly( self, rsa_key_pair: RSAKeyPair, jwks_provider: JWTVerifier, httpx_mock: HTTPXMock, mock_dns, ): - """If every key in the JWKS is unusable, verification fails + """If every key in the JWKS is unsupported, verification fails cleanly (returns None) rather than crashing - #4515.""" - unusable_only = { + okp_only = { "keys": [ cast( "JWKData", { - "kty": "RSA", - "kid": "malformed-key", + "kty": "OKP", + "crv": "Ed25519", + "kid": "ed25519-key", + "alg": "EdDSA", "use": "sig", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", }, ) ] } - httpx_mock.add_response(json=unusable_only) + httpx_mock.add_response(json=okp_only) token = rsa_key_pair.create_token( subject="test-user", issuer="https://test.example.com", audience="https://api.example.com", - kid="malformed-key", + kid="ed25519-key", ) access_token = await jwks_provider.load_access_token(token) @@ -856,14 +641,13 @@ class TestBearerTokenJWKS: async def test_jwks_token_validation_with_invalid_key( self, rsa_key_pair: RSAKeyPair, - rsa_key_pair_2: RSAKeyPair, jwks_provider: JWTVerifier, mock_jwks_data: JWKSData, httpx_mock: HTTPXMock, mock_dns, ): httpx_mock.add_response(json=mock_jwks_data) - token = rsa_key_pair_2.create_token( + token = RSAKeyPair.generate().create_token( subject="test-user", issuer="https://test.example.com", audience="https://api.example.com", diff --git a/tests/server/auth/test_jwt_provider_bearer.py b/tests/server/auth/test_jwt_provider_bearer.py index b649cb0c5..1be370481 100644 --- a/tests/server/auth/test_jwt_provider_bearer.py +++ b/tests/server/auth/test_jwt_provider_bearer.py @@ -13,6 +13,11 @@ from fastmcp.utilities.tests import run_server_async TEST_PUBLIC_IP = "93.184.216.34" +@pytest.fixture(scope="module") +def rsa_key_pair() -> RSAKeyPair: + return RSAKeyPair.generate() + + @pytest.fixture(scope="module") def bearer_token(rsa_key_pair: RSAKeyPair) -> str: return rsa_key_pair.create_token( @@ -389,14 +394,11 @@ class TestBearerToken: assert access_token is None async def test_invalid_signature_rejection( - self, - rsa_key_pair: RSAKeyPair, - rsa_key_pair_2: RSAKeyPair, - bearer_provider: JWTVerifier, + self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier ): """Test rejection of tokens with invalid signatures.""" # Create a token with a different key pair - other_key_pair = rsa_key_pair_2 + other_key_pair = RSAKeyPair.generate() token = other_key_pair.create_token( subject="test-user", issuer="https://test.example.com", @@ -515,10 +517,9 @@ class TestFastMCPBearerAuth: tools = await client.list_tools() # noqa: F841 assert "tools" not in locals() - async def test_token_with_bad_signature( - self, mcp_server_url: str, rsa_key_pair_2: RSAKeyPair - ): - token = rsa_key_pair_2.create_token() + async def test_token_with_bad_signature(self, mcp_server_url: str): + rsa_key_pair = RSAKeyPair.generate() + token = rsa_key_pair.create_token() with pytest.raises(MCPError): async with Client(mcp_server_url, auth=BearerAuth(token)) as client: diff --git a/tests/server/auth/test_multi_auth.py b/tests/server/auth/test_multi_auth.py index f0d3993cd..08aaa380b 100644 --- a/tests/server/auth/test_multi_auth.py +++ b/tests/server/auth/test_multi_auth.py @@ -5,7 +5,6 @@ from pydantic import AnyHttpUrl from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.providers.azure import AzureJWTVerifier from fastmcp.server.auth.providers.jwt import StaticTokenVerifier @@ -16,20 +15,6 @@ class RaisingVerifier(TokenVerifier): raise RuntimeError("simulated failure") -class UnderScopedVerifier(TokenVerifier): - """A verifier that returns a token missing its required scopes.""" - - async def verify_token(self, token: str) -> AccessToken: - return AccessToken(token=token, client_id="c", scopes=[]) - - -class UnderScopedAzureJWTVerifier(AzureJWTVerifier): - """An Azure verifier that returns a token missing its required scopes.""" - - async def verify_token(self, token: str) -> AccessToken: - return AccessToken(token=token, client_id="c", scopes=[]) - - class TestMultiAuthInit: """Test MultiAuth initialization and validation.""" @@ -122,80 +107,6 @@ class TestMultiAuthInit: auth = MultiAuth(server=provider) assert auth.required_scopes == ["read"] - def test_supported_scopes_from_server(self): - verifier = StaticTokenVerifier( - tokens={"t": {"client_id": "c", "scopes": ["read"]}}, - required_scopes=["read"], - ) - provider = RemoteAuthProvider( - token_verifier=verifier, - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - base_url="https://api.example.com", - scopes_supported=["api://client-id/read"], - challenge_scopes=["api://client-id/read"], - ) - - auth = MultiAuth(server=provider) - - assert auth.required_scopes == ["read"] - assert auth.scopes_supported == ["api://client-id/read"] - assert auth.challenge_scopes == ["api://client-id/read"] - - def test_supported_scopes_from_verifier_only_configuration(self): - verifier = StaticTokenVerifier( - tokens={"t": {"client_id": "c", "scopes": ["read"]}}, - ) - - auth = MultiAuth(verifiers=[verifier], required_scopes=["read"]) - - assert auth.scopes_supported == ["read"] - assert auth.challenge_scopes == ["read"] - - def test_challenge_scopes_translated_by_single_verifier(self): - verifier = AzureJWTVerifier( - client_id="client-id", - tenant_id="test-tenant", - required_scopes=["read"], - ) - - auth = MultiAuth(verifiers=[verifier], required_scopes=["admin"]) - - assert auth.challenge_scopes == ["api://client-id/admin"] - - def test_challenge_scopes_not_translated_by_multiple_verifiers(self): - first = AzureJWTVerifier( - client_id="first-client", - tenant_id="test-tenant", - required_scopes=["read"], - ) - second = AzureJWTVerifier( - client_id="second-client", - tenant_id="test-tenant", - required_scopes=["read"], - ) - - auth = MultiAuth(verifiers=[first, second], required_scopes=["admin"]) - - assert auth.challenge_scopes == ["admin"] - - def test_challenge_scopes_respect_required_scopes_override(self): - verifier = StaticTokenVerifier( - tokens={"t": {"client_id": "c", "scopes": ["read"]}}, - required_scopes=["read"], - ) - provider = RemoteAuthProvider( - token_verifier=verifier, - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - base_url="https://api.example.com", - scopes_supported=["api://client-id/read"], - challenge_scopes=["api://client-id/read"], - ) - - auth = MultiAuth(server=provider, required_scopes=["admin"]) - - assert auth.scopes_supported == ["api://client-id/read"] - assert auth.challenge_scopes == ["admin"] - class TestMultiAuthVerifyToken: """Test MultiAuth token verification chain.""" @@ -470,111 +381,6 @@ class TestMultiAuthIntegration: in response.headers["www-authenticate"] ) - async def test_multi_auth_uses_server_supported_scopes_in_auth_challenges(self): - """Challenges should match the request-facing scopes in delegated metadata.""" - verifier = UnderScopedVerifier(required_scopes=["read"]) - server = RemoteAuthProvider( - token_verifier=verifier, - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - base_url="https://api.example.com", - scopes_supported=["api://client-id/read"], - challenge_scopes=["api://client-id/read"], - ) - - auth = MultiAuth(server=server) - app = FastMCP("test", auth=auth).http_app(path="/mcp") - - async with httpx2.AsyncClient( - transport=httpx2.ASGITransport(app=app), - base_url="https://api.example.com", - ) as client: - missing_response = await client.get("/mcp") - narrow_response = await client.get( - "/mcp", headers={"Authorization": "Bearer narrow"} - ) - - assert missing_response.status_code == 401 - assert ( - 'scope="api://client-id/read"' - in missing_response.headers["www-authenticate"] - ) - assert narrow_response.status_code == 403 - assert ( - 'scope="api://client-id/read"' - in narrow_response.headers["www-authenticate"] - ) - - async def test_multi_auth_scope_override_wins_in_auth_challenges(self): - """Outer overrides are translated for both 401 and 403 challenges.""" - verifier = UnderScopedAzureJWTVerifier( - client_id="client-id", - tenant_id="test-tenant", - required_scopes=["read"], - ) - server = RemoteAuthProvider( - token_verifier=verifier, - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - base_url="https://api.example.com", - ) - - auth = MultiAuth(server=server, required_scopes=["admin"]) - app = FastMCP("test", auth=auth).http_app(path="/mcp") - - async with httpx2.AsyncClient( - transport=httpx2.ASGITransport(app=app), - base_url="https://api.example.com", - ) as client: - missing_response = await client.get("/mcp") - narrow_response = await client.get( - "/mcp", headers={"Authorization": "Bearer narrow"} - ) - - assert missing_response.status_code == 401 - assert ( - 'scope="api://client-id/admin"' - in missing_response.headers["www-authenticate"] - ) - assert ( - "api://client-id/read" not in missing_response.headers["www-authenticate"] - ) - assert narrow_response.status_code == 403 - assert ( - 'scope="api://client-id/admin"' - in narrow_response.headers["www-authenticate"] - ) - assert "api://client-id/read" not in narrow_response.headers["www-authenticate"] - - async def test_verifier_only_scope_translation_in_auth_challenges(self): - """A sole verifier translates challenge scopes for both 401 and 403.""" - verifier = UnderScopedAzureJWTVerifier( - client_id="client-id", - tenant_id="test-tenant", - required_scopes=["read"], - ) - - auth = MultiAuth(verifiers=[verifier], required_scopes=["read"]) - app = FastMCP("test", auth=auth).http_app(path="/mcp") - - async with httpx2.AsyncClient( - transport=httpx2.ASGITransport(app=app), - base_url="https://api.example.com", - ) as client: - missing_response = await client.get("/mcp") - narrow_response = await client.get( - "/mcp", headers={"Authorization": "Bearer narrow"} - ) - - assert missing_response.status_code == 401 - assert ( - 'scope="api://client-id/read"' - in missing_response.headers["www-authenticate"] - ) - assert narrow_response.status_code == 403 - assert ( - 'scope="api://client-id/read"' - in narrow_response.headers["www-authenticate"] - ) - async def test_multi_auth_override_propagates_to_served_metadata(self): """Override on MultiAuth must propagate so served metadata matches the challenge.""" verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/test_oauth_consent_flow.py index fae84068f..ed756c081 100644 --- a/tests/server/auth/test_oauth_consent_flow.py +++ b/tests/server/auth/test_oauth_consent_flow.py @@ -111,26 +111,6 @@ def oauth_proxy_https_remember(): ) -@pytest.fixture -def oauth_proxy_https_path(): - """OAuthProxy with a path component in base_url (no trailing slash). - - Exercises the RFC 9207 issuer consistency across a base_url shape where - naive normalization (e.g. force-appending a trailing slash) would produce - an `iss` value that no longer matches the discovery document's `issuer`. - """ - return OAuthProxy( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id="client-id", - upstream_client_secret="client-secret", - token_verifier=_Verifier(), - base_url="https://myserver.example/oauth", - client_storage=MemoryStore(), - jwt_signing_key="test-secret", - ) - - async def _start_flow( proxy: OAuthProxy, client_id: str, redirect: str ) -> tuple[str, str]: @@ -651,180 +631,11 @@ class TestConsentSecurity: q = parse_qs(parsed.query) assert q.get("error") == ["access_denied"] assert q.get("state") == ["client-state-xyz"] - assert q.get("iss") == ["https://myserver.example/"] # Signed denied cookie should be set assert "MCP_DENIED_CLIENTS" in ";\n".join( r.headers.get("set-cookie", "").splitlines() ) - async def test_deny_redirect_does_not_duplicate_iss_already_in_redirect_uri( - self, oauth_proxy_https_remember - ): - """RFC 9207 P2 regression: a registered redirect_uri may already - carry its own `iss` query parameter. Explicit consent denial must - not append a second `iss` on top of it -- RFC 6749 §3.1 forbids a - response parameter appearing more than once -- and every other - query byte on the registered URI (a valueless `flag` and a - non-UTF-8 percent-encoded `sig`) must survive untouched. - """ - client_redirect = "http://localhost:5009/callback?iss=tenant&flag&sig=%FF%FE" - txn_id, _ = await _start_flow( - oauth_proxy_https_remember, "client-dup-iss", client_redirect - ) - app = Starlette(routes=oauth_proxy_https_remember.get_routes()) - with TestClient(app) as c: - consent = c.get(f"/consent?txn_id={txn_id}") - csrf = _extract_csrf(consent.text) - assert csrf - for k, v in consent.cookies.items(): - c.cookies.set(k, v) - r = c.post( - "/consent", - data={"action": "deny", "txn_id": txn_id, "csrf_token": csrf}, - follow_redirects=False, - ) - assert r.status_code in (302, 303) - loc = r.headers.get("location", "") - query = urlparse(loc).query - q = parse_qs(query) - assert q.get("error") == ["access_denied"] - # Exactly one `iss`, corrected to the canonical value -- a - # duplicate would make this list have length 2. - assert q.get("iss") == ["https://myserver.example/"] - # Other query bytes from the registered redirect_uri survive - # byte-for-byte. - assert "flag" in query - assert "sig=%FF%FE" in query - - async def test_deny_redirect_issuer_matches_path_base_url_metadata( - self, oauth_proxy_https_path - ): - """Consent-denial `iss` must match the discovery document exactly. - - Regression test for a base_url with a path and no trailing slash - (`https://myserver.example/oauth`): the metadata `issuer` is the - unmodified base_url, so the denial redirect's `iss` must match it - byte-for-byte rather than force-appending a trailing slash. - """ - client_redirect = "http://localhost:5008/callback" - txn_id, _ = await _start_flow( - oauth_proxy_https_path, "client-path", client_redirect - ) - app = Starlette(routes=oauth_proxy_https_path.get_routes()) - with TestClient(app) as c: - metadata = c.get("/.well-known/oauth-authorization-server").json() - assert metadata["issuer"] == "https://myserver.example/oauth" - - consent = c.get(f"/consent?txn_id={txn_id}") - csrf = _extract_csrf(consent.text) - assert csrf - for k, v in consent.cookies.items(): - c.cookies.set(k, v) - r = c.post( - "/consent", - data={"action": "deny", "txn_id": txn_id, "csrf_token": csrf}, - follow_redirects=False, - ) - assert r.status_code in (302, 303) - q = parse_qs(urlparse(r.headers.get("location", "")).query) - assert q.get("error") == ["access_denied"] - assert q.get("iss") == [metadata["issuer"]] - - async def test_remembered_denial_redirects_with_issuer( - self, oauth_proxy_https_remember - ): - """Remembered consent denial redirects with RFC 9207 issuer.""" - client_id = "client-denied" - redirect = "http://localhost:5007/callback" - txn_id, _ = await _start_flow(oauth_proxy_https_remember, client_id, redirect) - app = Starlette(routes=oauth_proxy_https_remember.get_routes()) - with TestClient(app) as c: - consent = c.get(f"/consent?txn_id={txn_id}") - csrf = _extract_csrf(consent.text) - assert csrf - for k, v in consent.cookies.items(): - c.cookies.set(k, v) - r = c.post( - "/consent", - data={"action": "deny", "txn_id": txn_id, "csrf_token": csrf}, - follow_redirects=False, - ) - set_cookie = ";\n".join(r.headers.get("set-cookie", "").splitlines()) - m = re.search(r"__Host-MCP_DENIED_CLIENTS=([^;]+)", set_cookie) - assert m - denied_cookie = m.group(1) - - new_txn, _ = await _start_flow( - oauth_proxy_https_remember, client_id, redirect - ) - c.cookies.set("__Host-MCP_DENIED_CLIENTS", denied_cookie) - r2 = c.get( - f"/consent?txn_id={new_txn}", - headers={"Sec-Fetch-Site": "none"}, - follow_redirects=False, - ) - - assert r2.status_code in (302, 303) - loc = r2.headers.get("location", "") - parsed = urlparse(loc) - assert parsed.scheme == "http" and parsed.netloc.startswith("localhost") - q = parse_qs(parsed.query) - assert q.get("error") == ["access_denied"] - assert q.get("state") == ["client-state-xyz"] - assert q.get("iss") == ["https://myserver.example/"] - - async def test_remembered_denial_does_not_duplicate_iss_already_in_redirect_uri( - self, oauth_proxy_https_remember - ): - """RFC 9207 P2 regression: the *remembered/silent* denial path is a - separate call site from the explicit deny above, and must - independently avoid duplicating `iss` when the registered - redirect_uri already carries one. - """ - client_id = "client-denied-dup-iss" - redirect = "http://localhost:5010/callback?iss=tenant&flag&sig=%FF%FE" - txn_id, _ = await _start_flow(oauth_proxy_https_remember, client_id, redirect) - app = Starlette(routes=oauth_proxy_https_remember.get_routes()) - with TestClient(app) as c: - consent = c.get(f"/consent?txn_id={txn_id}") - csrf = _extract_csrf(consent.text) - assert csrf - for k, v in consent.cookies.items(): - c.cookies.set(k, v) - r = c.post( - "/consent", - data={"action": "deny", "txn_id": txn_id, "csrf_token": csrf}, - follow_redirects=False, - ) - set_cookie = ";\n".join(r.headers.get("set-cookie", "").splitlines()) - m = re.search(r"__Host-MCP_DENIED_CLIENTS=([^;]+)", set_cookie) - assert m - denied_cookie = m.group(1) - - new_txn, _ = await _start_flow( - oauth_proxy_https_remember, client_id, redirect - ) - c.cookies.set("__Host-MCP_DENIED_CLIENTS", denied_cookie) - r2 = c.get( - f"/consent?txn_id={new_txn}", - headers={"Sec-Fetch-Site": "none"}, - follow_redirects=False, - ) - - assert r2.status_code in (302, 303) - loc = r2.headers.get("location", "") - query = urlparse(loc).query - q = parse_qs(query) - assert q.get("error") == ["access_denied"] - assert q.get("state") == ["client-state-xyz"] - # Exactly one `iss`, corrected to the canonical value -- a - # duplicate would make this list have length 2. - assert q.get("iss") == ["https://myserver.example/"] - # Other query bytes from the registered redirect_uri survive - # byte-for-byte. - assert "flag" in query - assert "sig=%FF%FE" in query - async def test_approve_sets_cookie_and_redirects_to_upstream( self, oauth_proxy_https_remember ): diff --git a/tests/server/auth/test_oauth_mounting.py b/tests/server/auth/test_oauth_mounting.py index db4e9a77f..50bf67b1a 100644 --- a/tests/server/auth/test_oauth_mounting.py +++ b/tests/server/auth/test_oauth_mounting.py @@ -209,8 +209,7 @@ class TestOAuthMounting: Scenario: FastMCP server mounted at /api prefix - issuer_url: https://api.example.com (root level) - base_url: https://api.example.com/api (includes mount prefix) - - Expected: metadata declares endpoints at base_url and issuer at - issuer_url + - Expected: metadata declares endpoints at base_url """ # Create OAuth proxy with different base_url and issuer_url token_verifier = StaticTokenVerifier(tokens=test_tokens) @@ -262,10 +261,12 @@ class TestOAuthMounting: == "https://api.example.com/api/register" ) - # The issuer field reports issuer_url: it is the identifier the - # client used for RFC 8414 discovery, and §3.3 requires the two to - # match. Only the endpoint URLs follow base_url. - assert metadata["issuer"] == "https://api.example.com/" + # The issuer field should use base_url (where the server is actually running) + # Note: MCP SDK may or may not add a trailing slash + assert metadata["issuer"] in [ + "https://api.example.com/api", + "https://api.example.com/api/", + ] async def test_oauth_authorization_server_metadata_path_aware_discovery( self, test_tokens diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py index 72cf68c2a..15f2f8d41 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py @@ -11,10 +11,6 @@ from fastmcp.server.auth.auth import TokenVerifier from fastmcp.server.auth.cimd import CIMDDocument from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient -from fastmcp.server.auth.redirect_validation import ( - is_loopback_host, - is_redirect_uri_allowed_for_application_type, -) # Standard public IP used for DNS mocking in tests TEST_PUBLIC_IP = "93.184.216.34" @@ -496,316 +492,3 @@ class TestOAuthProxyCIMDClient: # NOT in CIMD but matches proxy pattern → rejected with pytest.raises(InvalidRedirectUriError): client.validate_redirect_uri(AnyUrl("http://localhost:9999/other")) - - -class TestRegisteredLoopbackPortFlexibility: - """The registered-URI port-flexible match uses the shared loopback classifier. - - `models.py` previously carried its own `_is_loopback_host` that only knew - `127.0.0.1`, so a client registered on another address in `127.0.0.0/8` - silently lost port flexibility and was rejected as unregistered. - """ - - @pytest.mark.parametrize( - "host", - ["127.0.0.1", "127.0.0.2", "127.5.5.5", "localhost", "app.localhost"], - ) - def test_loopback_range_keeps_port_flexibility(self, host: str): - client = ProxyDCRClient( - client_id="native", - client_secret="secret", - redirect_uris=[AnyUrl(f"http://{host}:3000/callback")], - ) - - uri = client.validate_redirect_uri(AnyUrl(f"http://{host}:54321/callback")) - assert str(uri) == f"http://{host}:54321/callback" - - def test_non_loopback_host_still_requires_exact_match(self): - """Port flexibility is loopback-only; other hosts must match exactly.""" - client = ProxyDCRClient( - client_id="external", - client_secret="secret", - redirect_uris=[AnyUrl("https://client.example.com:3000/callback")], - ) - - uri = client.validate_redirect_uri( - AnyUrl("https://client.example.com:3000/callback") - ) - assert str(uri) == "https://client.example.com:3000/callback" - - with pytest.raises(InvalidRedirectUriError): - client.validate_redirect_uri( - AnyUrl("https://client.example.com:54321/callback") - ) - - -class TestStoredApplicationTypeAtAuthorization: - """SEP-837: a stored client's application_type is enforced at authorization.""" - - def test_web_client_rejects_loopback_at_authorization(self): - """A registered web client cannot later authorize a loopback redirect.""" - client = ProxyDCRClient( - client_id="web", - client_secret="secret", - redirect_uris=[AnyUrl("https://client.example.com/callback")], - application_type="web", - ) - - uri = client.validate_redirect_uri( - AnyUrl("https://client.example.com/callback") - ) - assert str(uri) == "https://client.example.com/callback" - - with pytest.raises(InvalidRedirectUriError, match="application_type 'web'"): - client.validate_redirect_uri(AnyUrl("http://localhost:8080/callback")) - - def test_web_client_rejects_loopback_even_when_pattern_allows(self): - """The application_type check applies on top of the global allowlist.""" - client = ProxyDCRClient( - client_id="web", - client_secret="secret", - redirect_uris=[AnyUrl("https://client.example.com/callback")], - application_type="web", - allowed_redirect_uri_patterns=["http://localhost:*", "https://*/*"], - ) - - with pytest.raises(InvalidRedirectUriError, match="application_type 'web'"): - client.validate_redirect_uri(AnyUrl("http://localhost:8080/callback")) - - def test_native_client_accepts_loopback_at_authorization(self): - client = ProxyDCRClient( - client_id="native", - client_secret="secret", - redirect_uris=[AnyUrl("http://localhost:8080/callback")], - application_type="native", - ) - - uri = client.validate_redirect_uri(AnyUrl("http://localhost:55555/callback")) - assert str(uri) == "http://localhost:55555/callback" - - def test_web_client_rejects_localhost_namespace_at_authorization(self): - """The shared classifier means the namespace fix reaches this path too.""" - client = ProxyDCRClient( - client_id="web", - client_secret="secret", - redirect_uris=[AnyUrl("https://client.example.com/callback")], - application_type="web", - allowed_redirect_uri_patterns=["https://*/*"], - ) - - with pytest.raises(InvalidRedirectUriError, match="application_type 'web'"): - client.validate_redirect_uri(AnyUrl("https://app.localhost/callback")) - - -class TestApplicationTypeRedirectRules: - """SEP-837: application_type governs the web vs native redirect rules.""" - - @pytest.mark.parametrize( - "uri", - [ - "https://client.example.com/callback", - "https://app.example.com:8443/oauth/callback", - ], - ) - def test_web_accepts_https(self, uri: str): - assert is_redirect_uri_allowed_for_application_type(uri, "web") is True - - @pytest.mark.parametrize( - "uri", - [ - "http://127.0.0.1:8080/callback", - "http://localhost:12345/callback", - "http://[::1]:9000/callback", - "https://localhost/callback", - "com.example.app:/oauth/callback", - "myapp://callback", - "http://client.example.com/callback", - ], - ) - def test_web_rejects_loopback_and_custom_schemes(self, uri: str): - """Web clients must use https on a non-loopback host.""" - assert is_redirect_uri_allowed_for_application_type(uri, "web") is False - - @pytest.mark.parametrize( - "uri", - [ - "http://127.0.0.1:8080/callback", - "http://localhost:12345/callback", - "http://[::1]:9000/callback", - "com.example.app:/oauth/callback", - "cursor://anysphere.cursor-mcp/oauth/callback", - "myapp://callback", - "https://client.example.com/callback", - ], - ) - def test_native_accepts_loopback_and_custom_schemes(self, uri: str): - assert is_redirect_uri_allowed_for_application_type(uri, "native") is True - - @pytest.mark.parametrize( - "uri", - [ - "http://client.example.com/callback", - "http://example.com:8080/callback", - ], - ) - def test_native_rejects_non_loopback_cleartext_http(self, uri: str): - """Native may use cleartext http only against a loopback host.""" - assert is_redirect_uri_allowed_for_application_type(uri, "native") is False - - @pytest.mark.parametrize( - "uri", - [ - # Real MCP client callbacks — these must keep working. - "vscode://callback", - "vscode-insiders://callback", - "urn:ietf:wg:oauth:2.0:oob", - "cursor://anysphere.cursor-mcp/oauth/callback", - # Reverse-domain and plain app schemes. - "com.example.app://callback", - "com.example.app:/oauth/callback", - "myapp://callback", - ], - ) - def test_native_accepts_app_and_private_use_schemes(self, uri: str): - """Native clients keep every scheme outside the unsafe set. - - FastMCP deliberately does not try to classify a native client's scheme - as "private-use" versus "network transport": the IANA registry lists - `vscode` (an app-dispatch scheme) alongside `coap` and `smb`, so no - membership test separates the two without rejecting schemes that real - MCP clients depend on. - """ - assert is_redirect_uri_allowed_for_application_type(uri, "native") is True - - @pytest.mark.parametrize( - "host", - ["127.0.0.1", "127.0.0.2", "127.5.5.5", "127.255.255.254"], - ) - def test_web_rejects_entire_loopback_range(self, host: str): - """RFC 8252 §7.3 loopback is all of 127.0.0.0/8, not just 127.0.0.1. - - Checking only 127.0.0.1 would let a web client bypass the non-loopback - requirement with any other address in the range. - """ - uri = f"https://{host}/callback" - assert is_redirect_uri_allowed_for_application_type(uri, "web") is False - - @pytest.mark.parametrize( - "uri", - [ - "http://127.0.0.1:1234/cb", - "http://127.0.0.2:1234/cb", - "http://127.5.5.5:1234/cb", - "http://[::1]:1234/cb", - "http://localhost:1234/cb", - ], - ) - def test_native_accepts_entire_loopback_range(self, uri: str): - """The widened loopback range cuts both ways: native gains 127.0.0.0/8.""" - assert is_redirect_uri_allowed_for_application_type(uri, "native") is True - - -class TestLocalhostNamespaceIsLoopback: - """RFC 6761 §6.3 reserves the whole `localhost` namespace for the local machine.""" - - @pytest.mark.parametrize( - "host", - [ - "localhost", - "localhost.", # absolute (FQDN) form - "LOCALHOST", - "app.localhost", # reserved namespace - "api.app.localhost", - "App.LocalHost", - "evil.localhost", # .localhost is a reserved TLD — genuinely local - "127.0.0.1", - "127.0.0.1.", # absolute form of an IP literal - "127.0.0.2.", - "::1", - "[::1]", - ], - ) - def test_loopback_names_and_literals(self, host: str): - assert is_loopback_host(host) is True - - @pytest.mark.parametrize( - "host", - [ - # `localhost` as a *label* of a registrable domain is not local. The - # suffix test is anchored on a leading dot so these cannot spoof it. - "localhost.evil.com", - "localhost.evil.com.", - "notlocalhost", - "mylocalhost", - "localhostx", - "evil.com", - "", - ".", - ], - ) - def test_non_loopback_names_are_not_spoofable(self, host: str): - assert is_loopback_host(host) is False - - @pytest.mark.parametrize( - "uri", - [ - "https://app.localhost/cb", - "https://localhost./cb", - "https://api.app.localhost/cb", - "https://127.0.0.1./cb", - ], - ) - def test_web_rejects_localhost_namespace(self, uri: str): - """Web clients must not reach the local machine by name.""" - assert is_redirect_uri_allowed_for_application_type(uri, "web") is False - - @pytest.mark.parametrize( - "uri", - [ - "https://localhost.evil.com/cb", - "https://notlocalhost/cb", - ], - ) - def test_web_still_accepts_ordinary_public_https(self, uri: str): - """Names that merely contain 'localhost' remain ordinary public hosts.""" - assert is_redirect_uri_allowed_for_application_type(uri, "web") is True - - @pytest.mark.parametrize( - "uri", - [ - "http://app.localhost:3000/cb", - "http://localhost.:3000/cb", - "http://api.app.localhost:3000/cb", - "http://127.0.0.1.:3000/cb", - ], - ) - def test_native_accepts_localhost_namespace(self, uri: str): - """These are legitimate loopback dev callbacks and must not be rejected.""" - assert is_redirect_uri_allowed_for_application_type(uri, "native") is True - - @pytest.mark.parametrize( - "uri", - [ - "http://localhost.evil.com:3000/cb", - "http://notlocalhost:3000/cb", - ], - ) - def test_native_rejects_plain_http_to_non_loopback_lookalikes(self, uri: str): - assert is_redirect_uri_allowed_for_application_type(uri, "native") is False - - @pytest.mark.parametrize("application_type", ["web", "native"]) - @pytest.mark.parametrize( - "uri", - [ - "javascript:alert(document.cookie)//", - "data:text/html,<script>alert(1)</script>", - "file:///etc/passwd", - "vbscript:msgbox(1)", - ], - ) - def test_unsafe_schemes_rejected_for_all_types( - self, uri: str, application_type: str - ): - assert ( - is_redirect_uri_allowed_for_application_type(uri, application_type) is False - ) diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index 755049120..2636a1c83 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -982,139 +982,3 @@ class TestDiscoveryTimeout: ), ) assert timeout == 10 - - -class TestOIDCProxyValidScopes: - """Tests for the valid_scopes parameter on OIDCProxy.""" - - def test_valid_scopes_widens_advertised_set(self, valid_oidc_configuration_dict): - """valid_scopes broadens the advertised/registerable set beyond required_scopes.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - proxy = OIDCProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - required_scopes=["openid"], - valid_scopes=["openid", "email", "calendar"], - jwt_signing_key="test-secret", - ) - - assert proxy.client_registration_options is not None - assert proxy.client_registration_options.valid_scopes == [ - "openid", - "email", - "calendar", - ] - assert proxy.client_registration_options.default_scopes == [ - "openid", - "email", - "calendar", - ] - assert proxy._default_scope_str == "openid email calendar" - - def test_valid_scopes_defaults_to_required_scopes( - self, valid_oidc_configuration_dict - ): - """Omitting valid_scopes falls back to required_scopes (existing behavior).""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - proxy = OIDCProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - required_scopes=["read", "write"], - jwt_signing_key="test-secret", - ) - - assert proxy.client_registration_options is not None - assert proxy.client_registration_options.valid_scopes == ["read", "write"] - - def test_valid_scopes_with_custom_token_verifier( - self, valid_oidc_configuration_dict - ): - """valid_scopes is allowed alongside a custom token_verifier (no error).""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - custom_verifier = IntrospectionTokenVerifier( - introspection_url="https://example.com/oauth/introspect", - client_id="introspection-client", - client_secret="introspection-secret", - required_scopes=["read"], - ) - - proxy = OIDCProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - token_verifier=custom_verifier, - valid_scopes=["read", "write", "admin"], - jwt_signing_key="test-secret", - ) - - assert proxy.client_registration_options is not None - assert proxy.client_registration_options.valid_scopes == [ - "read", - "write", - "admin", - ] - - def test_valid_scopes_preserved_with_verify_id_token( - self, valid_oidc_configuration_dict - ): - """verify_id_token restores required_scopes without clobbering valid_scopes.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - proxy = OIDCProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - required_scopes=["read"], - valid_scopes=["read", "write", "admin"], - verify_id_token=True, - jwt_signing_key="test-secret", - ) - - # Enforcement floor is restored to required_scopes... - assert proxy.required_scopes == ["read"] - # ...but the advertised/registerable set keeps the full valid_scopes. - assert proxy.client_registration_options is not None - assert proxy.client_registration_options.valid_scopes == [ - "read", - "write", - "admin", - ] - assert proxy.client_registration_options.default_scopes == [ - "read", - "write", - "admin", - ] - assert proxy._default_scope_str == "read write admin" diff --git a/tests/server/auth/test_redirect_validation.py b/tests/server/auth/test_redirect_validation.py index f34f6097e..58bb29568 100644 --- a/tests/server/auth/test_redirect_validation.py +++ b/tests/server/auth/test_redirect_validation.py @@ -1,246 +1,15 @@ """Tests for redirect URI validation in OAuth flows.""" -import re -from pathlib import Path -from urllib.parse import parse_qs, parse_qsl, urlparse - import pytest from pydantic import AnyUrl -import fastmcp.server.auth from fastmcp.server.auth.redirect_validation import ( DEFAULT_LOCALHOST_PATTERNS, - add_query_params, - build_client_redirect, matches_allowed_pattern, - replace_query_param, validate_redirect_uri, ) -class TestAddQueryParams: - """Test that add_query_params preserves the registered callback's exact query bytes. - - A registered redirect URI may carry an opaque or signed query string. - Decoding it with parse_qsl and re-serializing with urlencode mutates it - (a valueless `?flag` becomes `?flag=`, and non-UTF-8 percent-encoded - bytes get replaced) which breaks clients that route on, or - cryptographically validate, the raw callback query. - """ - - def test_preserves_valueless_param_and_non_utf8_bytes(self): - original_query = "flag&sig=%FF%FE" - url = f"https://client.example.com/callback?{original_query}" - - result = add_query_params( - url, - { - "code": "abc123", - "state": "xyz state", - "iss": "https://issuer.example.com/", - }, - ) - - result_query = urlparse(result).query - - # The original query substring must survive byte-for-byte: the - # valueless `flag` must not become `flag=`, and the non-UTF-8 - # percent-encoded `sig` value must not be decoded/replaced. - assert result_query.startswith(f"{original_query}&") - - # New params are appended after a single `&`, correctly encoded. - appended = result_query[len(original_query) + 1 :] - assert dict(parse_qsl(appended)) == { - "code": "abc123", - "state": "xyz state", - "iss": "https://issuer.example.com/", - } - - def test_empty_query_has_no_stray_ampersand(self): - url = "https://client.example.com/callback" - - result = add_query_params(url, {"code": "abc123"}) - - assert result == "https://client.example.com/callback?code=abc123" - - def test_appends_to_existing_ordinary_query(self): - url = "https://client.example.com/callback?foo=bar" - - result = add_query_params(url, {"code": "abc123"}) - - assert result == "https://client.example.com/callback?foo=bar&code=abc123" - - -class TestReplaceQueryParam: - """Direct tests for the idempotent replace-or-append primitive that - `build_client_redirect` relies on to guarantee exactly one `iss`. - """ - - def test_replaces_existing_value_in_place_preserving_other_bytes(self): - url = "https://client.example.com/callback?iss=tenant&sig=%FF%FE" - - result = replace_query_param(url, "iss", "https://issuer.example.com/") - - assert urlparse(result).query == ( - "iss=https%3A%2F%2Fissuer.example.com%2F&sig=%FF%FE" - ) - - def test_appends_when_key_absent(self): - url = "https://client.example.com/callback?sig=%FF%FE" - - result = replace_query_param(url, "iss", "https://issuer.example.com/") - - assert urlparse(result).query == ( - "sig=%FF%FE&iss=https%3A%2F%2Fissuer.example.com%2F" - ) - - def test_only_first_occurrence_is_replaced(self): - """A key appearing twice in the input is left with one replaced - occurrence and one untouched -- callers must not feed this function - an already-duplicated key and expect deduplication.""" - url = "https://client.example.com/callback?iss=first&iss=second" - - result = replace_query_param(url, "iss", "https://issuer.example.com/") - - assert urlparse(result).query == ( - "iss=https%3A%2F%2Fissuer.example.com%2F&iss=second" - ) - - -class TestBuildClientRedirect: - """Tests for the single helper that owns the client-facing-redirect - `iss` invariant: exactly one `iss`, set to the canonical value, with - every other query byte preserved verbatim. - - This is the consolidation point for RFC 9207 support -- every redirect - the OAuth proxy sends back to a client (success or error, across all - five call sites that build one) must go through this function rather - than hand-building a params dict with its own `"iss"` key. - """ - - def test_appends_params_and_iss_when_absent(self): - url = "https://client.example.com/callback" - - result = build_client_redirect( - url, - {"code": "abc", "state": "xyz"}, - iss="https://issuer.example.com/", - ) - - assert dict(parse_qsl(urlparse(result).query)) == { - "code": "abc", - "state": "xyz", - "iss": "https://issuer.example.com/", - } - - def test_replaces_iss_already_present_in_registered_redirect_uri(self): - """A registered redirect_uri may legitimately carry its own `iss` - query parameter (e.g. a multi-tenant client encoding its tenant in - the callback URL). Blindly appending the server's issuer on top of - that would yield two `iss` values -- RFC 6749 §3.1 forbids a - response parameter appearing more than once, so strict clients - reject the response or read the wrong value. This is the P2 defect - this helper exists to close off at every call site, not just one. - """ - url = "https://client.example.com/callback?iss=tenant&sig=%FF%FE" - - result = build_client_redirect( - url, - {"code": "abc", "state": "xyz"}, - iss="https://issuer.example.com/", - ) - - result_query = urlparse(result).query - iss_values = parse_qs(result_query)["iss"] - assert len(iss_values) == 1 - assert iss_values == ["https://issuer.example.com/"] - - def test_preserves_valueless_param_and_non_utf8_bytes_alongside_existing_iss( - self, - ): - """Exact end-to-end reproduction of the worked example from the P2 - review comment: registered redirect_uri already has `iss`, a - valueless `flag`, and a non-UTF-8 percent-encoded `sig` -- all three - must survive the round trip through `add_query_params` + - `replace_query_param` untouched, with only `iss` rewritten in - place. - """ - url = "https://client.example.com/callback?iss=tenant&flag&sig=%FF%FE" - - result = build_client_redirect( - url, - {"code": "abc", "state": "xyz state"}, - iss="https://issuer.example.com/", - ) - - assert urlparse(result).query == ( - "iss=https%3A%2F%2Fissuer.example.com%2F" - "&flag&sig=%FF%FE&code=abc&state=xyz+state" - ) - - def test_rejects_iss_hand_specified_in_params(self): - """`iss` must come from the keyword-only `iss` argument, never from - the `params` dict -- this keeps exactly one place a caller can set - it, rather than two that could disagree.""" - with pytest.raises(ValueError, match="iss"): - build_client_redirect( - "https://client.example.com/callback", - {"code": "abc", "iss": "sneaky"}, - iss="https://issuer.example.com/", - ) - - def test_empty_params_does_not_add_stray_ampersand(self): - """The authorize-handler call site passes no extra params (it only - needs to fix up `iss` on a URL the SDK already built) -- an empty - `params` dict must not introduce a trailing/stray `&`.""" - url = "https://client.example.com/callback?code=abc&state=xyz" - - result = build_client_redirect(url, {}, iss="https://issuer.example.com/") - - assert urlparse(result).query == ( - "code=abc&state=xyz&iss=https%3A%2F%2Fissuer.example.com%2F" - ) - assert "&&" not in result - assert not result.endswith("&") - - -class TestNoHandSpecifiedIssOutsideHelper: - """Guard against a future call site reintroducing the duplicate-`iss` - bug this PR consolidates away. - - This is the sixth review round on the RFC 9207 `iss` work, and the last - two rounds were the same defect surfacing at different call sites: a - caller hand-building a params dict with its own `"iss"` key instead of - routing through `build_client_redirect`. Rather than trust that every - future redirect site remembers to do this, scan the directories that - build client-facing authorization redirects (`oauth_proxy/`, - `handlers/`) for a dict-literal `"iss"` key. `jwt_issuer.py` and the - JWT/Clerk providers legitimately use `"iss"` as a JWT claim name, but - those live outside these two directories, so this scan does not need to - special-case them. - """ - - def test_no_dict_literal_iss_key_in_redirect_building_modules(self): - auth_root = Path(fastmcp.server.auth.__file__).parent - scan_dirs = [auth_root / "oauth_proxy", auth_root / "handlers"] - iss_dict_key = re.compile(r"""["']iss["']\s*:""") - - offenders = [ - str(path) - for scan_dir in scan_dirs - for path in scan_dir.rglob("*.py") - if iss_dict_key.search(path.read_text()) - ] - - assert not offenders, ( - "Found a hand-specified 'iss' dict key outside " - "build_client_redirect() in: " - f"{offenders}. Route this redirect through " - "fastmcp.server.auth.redirect_validation.build_client_redirect " - "instead so the duplicate-iss invariant stays centralized." - ) - - class TestMatchesAllowedPattern: """Test wildcard pattern matching for redirect URIs.""" diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py index c7ef28fab..c56cd7b2b 100644 --- a/tests/server/auth/test_remote_auth_provider.py +++ b/tests/server/auth/test_remote_auth_provider.py @@ -180,44 +180,6 @@ class TestRemoteAuthProvider: "https://api.example.com/mcp" ) - def test_init_preserves_all_legacy_positional_slots(self, test_tokens): - token_verifier = StaticTokenVerifier( - tokens=test_tokens, required_scopes=["read"] - ) - documentation_url = AnyHttpUrl("https://docs.example.com/auth") - - provider = RemoteAuthProvider( - token_verifier, - [AnyHttpUrl("https://auth.example.com")], - "https://auth.example.com/proxy", - ["read"], - "https://api.example.com", - "Example API", - documentation_url, - ) - - assert provider._scopes_supported == ["read"] - assert provider.resource_base_url == AnyHttpUrl("https://api.example.com/") - assert provider.resource_name == "Example API" - assert provider.resource_documentation == documentation_url - assert provider._challenge_scopes is None - - def test_challenge_scope_translation_falls_back_for_protocol_verifier(self): - class ProtocolVerifier: - required_scopes = ["read"] - scopes_supported = ["read", "admin"] - - async def verify_token(self, token: str): - return None - - provider = RemoteAuthProvider( - token_verifier=ProtocolVerifier(), # ty: ignore[invalid-argument-type] - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - base_url="https://api.example.com", - ) - - assert provider.get_challenge_scopes() == ["read"] - class TestRemoteAuthProviderIntegration: """Integration tests for RemoteAuthProvider with FastMCP server.""" diff --git a/tests/server/auth/test_ssrf_protection.py b/tests/server/auth/test_ssrf_protection.py index 8d23ae501..08c2f3416 100644 --- a/tests/server/auth/test_ssrf_protection.py +++ b/tests/server/auth/test_ssrf_protection.py @@ -3,13 +3,11 @@ This module tests the ssrf.py module which provides SSRF-protected HTTP fetching. """ -import socket from unittest.mock import AsyncMock, MagicMock, patch import httpx2 import pytest -import fastmcp from fastmcp.server.auth.ssrf import ( SSRFError, SSRFFetchError, @@ -17,41 +15,6 @@ from fastmcp.server.auth.ssrf import ( ssrf_safe_fetch, validate_url, ) -from fastmcp.utilities.tests import temporary_settings - - -def _mock_httpx_client( - *, - status_code: int = 200, - headers: dict[str, str] | None = None, - body_chunks: list[bytes] | None = None, -) -> AsyncMock: - """Build a mock httpx2.AsyncClient whose stream() yields a canned response. - - The returned client's ``.stream.call_args`` exposes the request that was made. - """ - if headers is None: - headers = {"content-length": "2"} - if body_chunks is None: - body_chunks = [b"ok"] - - mock_stream = MagicMock() - mock_stream.status_code = status_code - mock_stream.headers = headers - mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - async def aiter_bytes(): - for chunk in body_chunks: - yield chunk - - mock_stream.aiter_bytes = aiter_bytes - - mock_client = AsyncMock() - mock_client.stream = MagicMock(return_value=mock_stream) - mock_client.__aenter__.return_value = mock_client - mock_client.__aexit__ = AsyncMock(return_value=None) - return mock_client class TestIsIPAllowed: @@ -543,316 +506,3 @@ class TestStreamingResponseSizeLimit: with pytest.raises(SSRFFetchError, match="too large"): await ssrf_safe_fetch("https://example.com/api", max_size=5120) - - -class TestProxyMode: - """Tests for FASTMCP_SSRF_TRUST_PROXY (proxy trust) mode. - - In proxy mode FastMCP skips its own DNS resolution and IP blocklist. Rather than - predicting whether httpx2 would route a request through a proxy -- a strategy - that broke three times chasing different NO_PROXY forms (port-qualified, IPv6, - scheme-qualified) -- it reads the proxy URL directly from the environment and - hands it to httpx2 explicitly with trust_env=False, so the request is provably - routed through that proxy rather than predicted to be. NO_PROXY is therefore not - evaluated in this mode. The scheme (HTTPS) and host checks still apply. - """ - - @pytest.fixture(autouse=True) - def _clear_proxy_env(self, monkeypatch): - """Start every test from a clean slate for both spellings of every proxy - variable, so a proxy inherited from the host/CI environment (or left behind - by another test) can't leak in and make behavior non-deterministic.""" - for name in ( - "HTTP_PROXY", - "http_proxy", - "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", - "NO_PROXY", - "no_proxy", - ): - monkeypatch.delenv(name, raising=False) - - def test_flag_defaults_to_false(self): - """The trust-proxy flag must be off by default (no silent weakening).""" - assert fastmcp.settings.ssrf_trust_proxy is False - - async def test_validate_url_skips_resolution_and_blocklist(self, monkeypatch): - """Proxy mode returns resolved_ips=[] without resolving or blocklisting, and - carries the configured proxy URL for the fetch to use.""" - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("fastmcp.server.auth.ssrf.resolve_hostname") as mock_resolve, - patch("fastmcp.server.auth.ssrf.is_ip_allowed") as mock_blocklist, - ): - result = await validate_url("https://example.com/path") - - assert result.resolved_ips == [] - assert result.original_url == "https://example.com/path" - assert result.hostname == "example.com" - assert result.proxy_url == "http://proxy.internal:3128" - mock_resolve.assert_not_called() - mock_blocklist.assert_not_called() - - async def test_validate_url_still_rejects_http(self, monkeypatch): - """Proxy mode keeps the HTTPS-only scheme check.""" - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - with temporary_settings(ssrf_trust_proxy=True): - with pytest.raises(SSRFError, match="must use HTTPS"): - await validate_url("http://example.com/path") - - async def test_validate_url_still_rejects_missing_host(self, monkeypatch): - """Proxy mode keeps the host check.""" - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - with temporary_settings(ssrf_trust_proxy=True): - with pytest.raises(SSRFError, match="must have a host"): - await validate_url("https:///path") - - async def test_validate_url_still_enforces_require_path(self, monkeypatch): - """Proxy mode keeps the require_path check (CIMD).""" - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - with temporary_settings(ssrf_trust_proxy=True): - with pytest.raises(SSRFError, match="non-root path"): - await validate_url("https://example.com/", require_path=True) - - async def test_raises_when_no_proxy_is_configured(self): - """No proxy in the environment → refuse rather than fetch unprotected.""" - with temporary_settings(ssrf_trust_proxy=True): - with pytest.raises(SSRFError, match="no HTTPS_PROXY/ALL_PROXY"): - await validate_url("https://example.com/path") - - async def test_fetch_refuses_end_to_end_when_no_proxy_configured(self): - """The refusal surfaces through ssrf_safe_fetch: no client is ever built. - - The whole point of the hard failure is that the *fetch* cannot proceed, so - this drives it through the public entrypoint and asserts no httpx client is - ever constructed — the request never leaves the process with the blocklist - disabled. - """ - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("httpx2.AsyncClient") as mock_client_class, - ): - with pytest.raises(SSRFError, match="no HTTPS_PROXY/ALL_PROXY"): - await ssrf_safe_fetch("https://example.com/api") - - mock_client_class.assert_not_called() - - async def test_https_proxy_used_explicitly(self, monkeypatch): - """HTTPS_PROXY is passed to httpx2 explicitly with trust_env disabled, and a - single request goes to the original hostname URL — not an IP literal. - - This is the property the whole redesign rests on: with an explicit proxy= - and trust_env=False, httpx2 has no environment-based routing decision left - to make differently than assumed. - """ - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - mock_client = _mock_httpx_client() - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("fastmcp.server.auth.ssrf.resolve_hostname") as mock_resolve, - patch("httpx2.AsyncClient", return_value=mock_client) as mock_client_class, - ): - content = await ssrf_safe_fetch("https://example.com/api") - - assert content == b"ok" - mock_resolve.assert_not_called() - - client_kwargs = mock_client_class.call_args[1] - assert client_kwargs["proxy"] == "http://proxy.internal:3128" - assert client_kwargs["trust_env"] is False - - # A single request to the original hostname URL — not an IP literal. - assert mock_client.stream.call_count == 1 - url_called = mock_client.stream.call_args[0][1] - assert url_called == "https://example.com/api" - - # No Host override and no SNI override — the client derives both from the URL. - call_kwargs = mock_client.stream.call_args[1] - assert "Host" not in call_kwargs["headers"] - assert call_kwargs["extensions"] == {} - - # Redirects stay disabled and TLS verification stays on. - assert client_kwargs["follow_redirects"] is False - assert client_kwargs["verify"] is True - - async def test_all_proxy_used_as_fallback(self, monkeypatch): - """ALL_PROXY routes the fetch when HTTPS_PROXY is not set.""" - monkeypatch.setenv("ALL_PROXY", "http://all-proxy.internal:3128") - mock_client = _mock_httpx_client() - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("fastmcp.server.auth.ssrf.resolve_hostname"), - patch("httpx2.AsyncClient", return_value=mock_client) as mock_client_class, - ): - content = await ssrf_safe_fetch("https://example.com/api") - - assert content == b"ok" - client_kwargs = mock_client_class.call_args[1] - assert client_kwargs["proxy"] == "http://all-proxy.internal:3128" - assert client_kwargs["trust_env"] is False - - async def test_https_proxy_preferred_over_all_proxy(self, monkeypatch): - """When both are set, HTTPS_PROXY takes priority.""" - monkeypatch.setenv("HTTPS_PROXY", "http://https-proxy.internal:3128") - monkeypatch.setenv("ALL_PROXY", "http://all-proxy.internal:3128") - mock_client = _mock_httpx_client() - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("fastmcp.server.auth.ssrf.resolve_hostname"), - patch("httpx2.AsyncClient", return_value=mock_client) as mock_client_class, - ): - await ssrf_safe_fetch("https://example.com/api") - - proxy_used = mock_client_class.call_args[1]["proxy"] - assert proxy_used == "http://https-proxy.internal:3128" - - async def test_no_proxy_is_not_honored(self, monkeypatch): - """Documents the behavior change: a NO_PROXY entry that would previously have - matched the target host no longer excludes it. The fetch still proceeds - through the configured proxy rather than being refused, because routing a - NO_PROXY'd host through the proxy is strictly safer than the alternative — - fetching it direct with the IP blocklist already disabled. - """ - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - monkeypatch.setenv("NO_PROXY", "example.com") - mock_client = _mock_httpx_client() - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("fastmcp.server.auth.ssrf.resolve_hostname"), - patch("httpx2.AsyncClient", return_value=mock_client) as mock_client_class, - ): - content = await ssrf_safe_fetch("https://example.com/api") - - assert content == b"ok" - client_kwargs = mock_client_class.call_args[1] - assert client_kwargs["proxy"] == "http://proxy.internal:3128" - assert client_kwargs["trust_env"] is False - - async def test_fetch_preserves_request_headers_but_drops_host(self, monkeypatch): - """Caller headers pass through, but a caller-supplied Host is dropped.""" - from fastmcp.server.auth.ssrf import ssrf_safe_fetch_response - - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - mock_client = _mock_httpx_client() - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("fastmcp.server.auth.ssrf.resolve_hostname"), - patch("httpx2.AsyncClient", return_value=mock_client), - ): - await ssrf_safe_fetch_response( - "https://example.com/api", - request_headers={"If-None-Match": "etag", "Host": "evil.example"}, - ) - - sent_headers = mock_client.stream.call_args[1]["headers"] - assert sent_headers["If-None-Match"] == "etag" - assert "Host" not in sent_headers - - async def test_fetch_size_limit_preserved(self, monkeypatch): - """Proxy mode still enforces the response size limit during streaming.""" - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - big_chunks = [b"x" * 1024 for _ in range(10)] - mock_client = _mock_httpx_client(headers={}, body_chunks=big_chunks) - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("fastmcp.server.auth.ssrf.resolve_hostname"), - patch("httpx2.AsyncClient", return_value=mock_client), - ): - with pytest.raises(SSRFFetchError, match="too large"): - await ssrf_safe_fetch("https://example.com/api", max_size=5120) - - async def test_fetch_status_check_preserved(self, monkeypatch): - """Proxy mode still rejects non-allowed status codes.""" - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - mock_client = _mock_httpx_client(status_code=404, body_chunks=[b"no"]) - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("fastmcp.server.auth.ssrf.resolve_hostname"), - patch("httpx2.AsyncClient", return_value=mock_client), - ): - with pytest.raises(SSRFFetchError, match="HTTP 404"): - await ssrf_safe_fetch("https://example.com/api") - - async def test_gaierror_repro_succeeds_through_proxy(self, monkeypatch): - """Reproduces issue #4292: on a host with no external DNS at all (every - getaddrinfo() call raises gaierror), the OAuth/JWKS fetch still succeeds in - proxy-trust mode, because DNS resolution is never attempted — only HTTPS_PROXY - is read and the proxy resolves the target. This is the reporter's exact - failure mode, and the strongest proof the redesign closes the issue: unlike - other tests in this class, resolve_hostname itself is *not* mocked, so if - proxy mode ever regressed into calling it, this test would fail with SSRFError - instead of succeeding. - """ - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - - def _no_dns(*args, **kwargs): - raise socket.gaierror("Name or service not known") - - monkeypatch.setattr(socket, "getaddrinfo", _no_dns) - - mock_client = _mock_httpx_client() - with ( - temporary_settings(ssrf_trust_proxy=True), - patch("httpx2.AsyncClient", return_value=mock_client) as mock_client_class, - ): - content = await ssrf_safe_fetch("https://example.com/api") - - assert content == b"ok" - client_kwargs = mock_client_class.call_args[1] - assert client_kwargs["proxy"] == "http://proxy.internal:3128" - assert client_kwargs["trust_env"] is False - assert mock_client.stream.call_args[0][1] == "https://example.com/api" - - async def test_default_mode_still_resolves_and_pins(self): - """Regression: with the flag off, resolution + blocklist + IP pinning still - apply, and no explicit proxy is passed to the client.""" - resolved_ip = "93.184.216.34" - mock_client = _mock_httpx_client() - with ( - patch( - "fastmcp.server.auth.ssrf.resolve_hostname", - return_value=[resolved_ip], - ) as mock_resolve, - patch("httpx2.AsyncClient", return_value=mock_client) as mock_client_class, - ): - assert fastmcp.settings.ssrf_trust_proxy is False - await ssrf_safe_fetch("https://example.com/api") - - mock_resolve.assert_called_once() - - # Connection is pinned to the resolved IP literal, with Host + SNI = hostname. - call_args = mock_client.stream.call_args - url_called = call_args[0][1] - assert resolved_ip in url_called - assert call_args[1]["headers"]["Host"] == "example.com" - assert call_args[1]["extensions"] == {"sni_hostname": "example.com"} - - # No explicit proxy is passed, and trust_env keeps its normal default. - client_kwargs = mock_client_class.call_args[1] - assert client_kwargs["proxy"] is None - assert client_kwargs["trust_env"] is True - - async def test_default_mode_ignores_proxy_env_vars(self, monkeypatch): - """Regression: proxy env vars — including a hostile NO_PROXY that previously - caused non-deterministic failures — must not affect the default (non-trust) - path at all, since it never reads them.""" - monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") - monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1") - resolved_ip = "93.184.216.34" - mock_client = _mock_httpx_client() - with ( - patch( - "fastmcp.server.auth.ssrf.resolve_hostname", - return_value=[resolved_ip], - ) as mock_resolve, - patch("httpx2.AsyncClient", return_value=mock_client) as mock_client_class, - ): - assert fastmcp.settings.ssrf_trust_proxy is False - await ssrf_safe_fetch("https://example.com/api") - - mock_resolve.assert_called_once() - assert mock_client_class.call_args[1]["proxy"] is None - assert mock_client_class.call_args[1]["trust_env"] is True diff --git a/tests/server/auth/test_static_token_verifier.py b/tests/server/auth/test_static_token_verifier.py index 8a7f0507b..6b51edcef 100644 --- a/tests/server/auth/test_static_token_verifier.py +++ b/tests/server/auth/test_static_token_verifier.py @@ -27,7 +27,6 @@ class TestStaticTokenVerifier: "client_id": "test-client", "scopes": ["read", "write"], "expires_at": None, - "sub": "user-42", }, "scoped-token": {"client_id": "limited-client", "scopes": ["read"]}, } @@ -40,14 +39,12 @@ class TestStaticTokenVerifier: assert result.scopes == ["read", "write"] assert result.token == "valid-token" assert result.expires_at is None - assert result.subject == "user-42" - # Test token with different scopes and no "sub" entry + # Test token with different scopes result = await verifier.verify_token("scoped-token") assert isinstance(result, AccessToken) assert result.client_id == "limited-client" assert result.scopes == ["read"] - assert result.subject is None # Test invalid token result = await verifier.verify_token("invalid-token") @@ -103,7 +100,7 @@ class TestStaticTokenVerifier: """Test that server raises error when both OAuth and TokenVerifier provided.""" from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider - oauth_provider = InMemoryOAuthProvider(base_url="http://test.com") + oauth_provider = InMemoryOAuthProvider("http://test.com") token_verifier = StaticTokenVerifier({"token": {"client_id": "test"}}) # This should work - OAuth provider diff --git a/tests/server/http/test_bearer_auth_backend.py b/tests/server/http/test_bearer_auth_backend.py index a82094cba..58d103635 100644 --- a/tests/server/http/test_bearer_auth_backend.py +++ b/tests/server/http/test_bearer_auth_backend.py @@ -11,6 +11,11 @@ from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair class TestBearerAuthBackendTokenVerifierIntegration: """Test BearerAuthBackend works with TokenVerifier protocol.""" + @pytest.fixture + def rsa_key_pair(self) -> RSAKeyPair: + """Generate RSA key pair for testing.""" + return RSAKeyPair.generate() + @pytest.fixture def jwt_verifier(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier: """Create JWTVerifier for testing.""" diff --git a/tests/server/http/test_http_auth_middleware.py b/tests/server/http/test_http_auth_middleware.py index 12c1c9f54..6a2340f8a 100644 --- a/tests/server/http/test_http_auth_middleware.py +++ b/tests/server/http/test_http_auth_middleware.py @@ -9,7 +9,7 @@ from starlette.testclient import TestClient from starlette.types import Receive, Scope, Send from fastmcp.server import FastMCP -from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair from fastmcp.server.http import HostOriginGuardMiddleware, create_streamable_http_app INITIALIZE_REQUEST = { @@ -80,6 +80,11 @@ async def _guard_status( class TestStreamableHTTPAppResourceMetadataURL: """Test resource_metadata_url logic in create_streamable_http_app.""" + @pytest.fixture + def rsa_key_pair(self) -> RSAKeyPair: + """Generate RSA key pair for testing.""" + return RSAKeyPair.generate() + @pytest.fixture def bearer_auth_provider(self, rsa_key_pair): provider = JWTVerifier( diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 704baebc3..a2cd49774 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -1,43 +1,14 @@ import json import pytest -from docket import Docket -from fastmcp_tasks.context import _recall_snapshot, get_task_context from mcp_types import TextContent, TextResourceContents from starlette.requests import Request -from fastmcp.server.dependencies import get_http_request -from fastmcp.server.http import _current_http_request +from fastmcp.client import Client +from fastmcp.client.transports import SSETransport, StreamableHttpTransport +from fastmcp.server.dependencies import CurrentHeaders, CurrentRequest, get_http_request from fastmcp.server.server import FastMCP -from fastmcp.utilities.tests import ASGIServer, asgi_server -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import running_task_server, submit_task, wait_for_task - - -@pytest.fixture -def reset_docket_memory_server(): - """Force a fresh memory:// Docket server bound to this test's event loop.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - -def _http_request_with_headers(headers: dict[str, str]) -> Request: - """Build a minimal Starlette HTTP request carrying the given headers.""" - raw_headers = [(k.lower().encode(), v.encode()) for k, v in headers.items()] - scope = { - "type": "http", - "method": "POST", - "path": "/mcp", - "headers": raw_headers, - "query_string": b"", - "scheme": "http", - "server": ("testserver", 80), - "client": ("testclient", 12345), - } - return Request(scope) +from fastmcp.utilities.tests import run_server_async def fastmcp_server(): @@ -73,21 +44,25 @@ def fastmcp_server(): async def shttp_server(): """Start a test server with StreamableHttp transport.""" server = fastmcp_server() - async with asgi_server(server, transport="http") as running_server: - yield running_server + async with run_server_async(server, transport="http") as url: + yield url @pytest.fixture async def sse_server(): """Start a test server with SSE transport.""" server = fastmcp_server() - async with asgi_server(server, transport="sse") as running_server: - yield running_server + async with run_server_async(server, transport="sse") as url: + yield url -async def test_http_headers_resource_shttp(shttp_server: ASGIServer): +async def test_http_headers_resource_shttp(shttp_server: str): """Test getting HTTP headers from the server.""" - async with shttp_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client: + async with Client( + transport=StreamableHttpTransport( + shttp_server, headers={"X-DEMO-HEADER": "ABC"} + ) + ) as client: raw_result = await client.read_resource("request://headers") assert isinstance(raw_result[0], TextResourceContents) json_result = json.loads(raw_result[0].text) @@ -95,9 +70,11 @@ async def test_http_headers_resource_shttp(shttp_server: ASGIServer): assert json_result["x-demo-header"] == "ABC" -async def test_http_headers_resource_sse(sse_server: ASGIServer): +async def test_http_headers_resource_sse(sse_server: str): """Test getting HTTP headers from the server.""" - async with sse_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client: + async with Client( + transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + ) as client: raw_result = await client.read_resource("request://headers") assert isinstance(raw_result[0], TextResourceContents) json_result = json.loads(raw_result[0].text) @@ -105,24 +82,34 @@ async def test_http_headers_resource_sse(sse_server: ASGIServer): assert json_result["x-demo-header"] == "ABC" -async def test_http_headers_tool_shttp(shttp_server: ASGIServer): +async def test_http_headers_tool_shttp(shttp_server: str): """Test getting HTTP headers from the server.""" - async with shttp_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client: + async with Client( + transport=StreamableHttpTransport( + shttp_server, headers={"X-DEMO-HEADER": "ABC"} + ) + ) as client: result = await client.call_tool("get_headers_tool") assert "x-demo-header" in result.data assert result.data["x-demo-header"] == "ABC" -async def test_http_headers_tool_sse(sse_server: ASGIServer): - async with sse_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client: +async def test_http_headers_tool_sse(sse_server: str): + async with Client( + transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + ) as client: result = await client.call_tool("get_headers_tool") assert "x-demo-header" in result.data assert result.data["x-demo-header"] == "ABC" -async def test_http_headers_prompt_shttp(shttp_server: ASGIServer): +async def test_http_headers_prompt_shttp(shttp_server: str): """Test getting HTTP headers from the server.""" - async with shttp_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client: + async with Client( + transport=StreamableHttpTransport( + shttp_server, headers={"X-DEMO-HEADER": "ABC"} + ) + ) as client: result = await client.get_prompt("get_headers_prompt") assert isinstance(result.messages[0].content, TextContent) json_result = json.loads(result.messages[0].content.text) @@ -130,9 +117,11 @@ async def test_http_headers_prompt_shttp(shttp_server: ASGIServer): assert json_result["x-demo-header"] == "ABC" -async def test_http_headers_prompt_sse(sse_server: ASGIServer): +async def test_http_headers_prompt_sse(sse_server: str): """Test getting HTTP headers from the server.""" - async with sse_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client: + async with Client( + transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + ) as client: result = await client.get_prompt("get_headers_prompt") assert isinstance(result.messages[0].content, TextContent) json_result = json.loads(result.messages[0].content.text) @@ -140,7 +129,7 @@ async def test_http_headers_prompt_sse(sse_server: ASGIServer): assert json_result["x-demo-header"] == "ABC" -async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer): +async def test_get_http_headers_excludes_content_type(sse_server: str): """Test that get_http_headers() excludes content-type header (issue #3097). This prevents HTTP 415 errors when forwarding headers to downstream APIs @@ -155,13 +144,16 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer): """Check that problematic headers are excluded from get_http_headers().""" return get_http_headers() - async with asgi_server(server, transport="sse") as running_server: - async with running_server.client( - headers={ - "Content-Type": "application/json", - "Accept": "application/json", - "X-Custom-Header": "should-be-included", - } + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport( + url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Custom-Header": "should-be-included", + }, + ) ) as client: result = await client.call_tool("check_excluded_headers") headers = result.data @@ -177,80 +169,51 @@ async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer): assert headers["x-custom-header"] == "should-be-included" -def _worker_snapshot_headers() -> dict[str, str]: - """Read the HTTP headers snapshotted at task submission from inside a worker.""" - task_info = get_task_context() - snapshot = _recall_snapshot(task_info.task_id) if task_info is not None else None - if snapshot is None or snapshot.http_headers is None: - return {} - return dict(snapshot.http_headers) - - -async def test_background_task_can_read_snapshotted_request_headers( - reset_docket_memory_server, -): - """A background task worker reads the HTTP headers snapshotted at submission. - - There is no client task-submission API yet (Phase 4), so the task is driven - in-process: an HTTP request is bound while the task is submitted, and the - worker reads the request headers back from the restored task-context - snapshot. - """ +async def test_background_task_can_read_snapshotted_request_headers(): + """Background tools can still access request headers via get_http_request().""" server = FastMCP() - server.add_extension(TasksExtension()) @server.tool(task=True) async def check_request_header() -> str: - return _worker_snapshot_headers().get("x-tenant-id", "missing") + request = get_http_request() + return request.headers.get("x-tenant-id", "missing") - request = _http_request_with_headers({"X-Tenant-ID": "tenant-123"}) - async with running_task_server(server): - token = _current_http_request.set(request) - try: - created = await submit_task(server, "check_request_header", {}) - finally: - _current_http_request.reset(token) - - final = await wait_for_task(server, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "tenant-123"} + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport(url, headers={"X-Tenant-ID": "tenant-123"}) + ) as client: + task = await client.call_tool("check_request_header", task=True) + result = await task.result() + assert result.data == "tenant-123" -async def test_background_task_snapshot_preserves_all_request_headers( - reset_docket_memory_server, -): - """The task snapshot preserves every request header, including authorization.""" +async def test_background_task_current_http_dependencies_restore_headers(): + """CurrentHeaders/CurrentRequest work in task workers without explicit Context.""" server = FastMCP() - server.add_extension(TasksExtension()) @server.tool(task=True) - async def check_headers() -> dict[str, str]: - headers = _worker_snapshot_headers() + async def check_headers( + headers: dict[str, str] = CurrentHeaders(), + request: Request = CurrentRequest(), + ) -> dict[str, str]: return { "authorization": headers.get("authorization", "missing"), - "tenant": headers.get("x-tenant-id", "missing"), + "tenant": request.headers.get("x-tenant-id", "missing"), } - request = _http_request_with_headers( - { - "Authorization": "Bearer tenant-token", - "X-Tenant-ID": "tenant-456", - } - ) - async with running_task_server(server): - token = _current_http_request.set(request) - try: - created = await submit_task(server, "check_headers", {}) - finally: - _current_http_request.reset(token) - - final = await wait_for_task(server, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == { - "authorization": "Bearer tenant-token", - "tenant": "tenant-456", - } + async with run_server_async(server, transport="sse") as url: + async with Client( + transport=SSETransport( + url, + headers={ + "Authorization": "Bearer tenant-token", + "X-Tenant-ID": "tenant-456", + }, + ) + ) as client: + task = await client.call_tool("check_headers", task=True) + result = await task.result() + assert result.data == { + "authorization": "Bearer tenant-token", + "tenant": "tenant-456", + } diff --git a/tests/server/http/test_routable_headers.py b/tests/server/http/test_routable_headers.py deleted file mode 100644 index 99c02c50e..000000000 --- a/tests/server/http/test_routable_headers.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Routable transport headers (SEP-2243) survive a FastMCP HTTP round trip. - -The MCP Python SDK emits the routing headers on the client (`ClientSession`) and -validates them on the modern streamable-HTTP server transport. These tests are -FastMCP's regression guard: they prove FastMCP's HTTP layer neither strips nor -blocks the headers, so a gateway sitting in front of a FastMCP server can route -on them. The tool echoes back the raw request headers it received, letting the -test assert on exactly what reached the server. -""" - -from typing import Annotated - -from pydantic import Field - -from fastmcp.server.dependencies import get_http_request -from fastmcp.server.server import FastMCP -from fastmcp.utilities.tests import asgi_server - - -def _echo_server() -> FastMCP: - server = FastMCP() - - @server.tool - def echo_headers( - tenant: Annotated[ - str, Field(json_schema_extra={"x-mcp-header": "Tenant"}) - ] = "acme", - ) -> dict[str, str]: - """Return the raw HTTP headers the server received for this request.""" - return dict(get_http_request().headers) - - return server - - -async def test_mcp_method_and_name_headers_reach_server(): - """`Mcp-Method` and `Mcp-Name` set by the SDK client arrive at the server.""" - async with asgi_server(_echo_server(), transport="http") as running_server: - async with running_server.client() as client: - result = await client.call_tool("echo_headers") - - headers = result.data - assert headers["mcp-method"] == "tools/call" - assert headers["mcp-name"] == "echo_headers" - - -async def test_mcp_param_header_reaches_server(): - """An `x-mcp-header` annotated parameter is mirrored into `Mcp-Param-*`. - - The SDK client only emits `Mcp-Param-*` once it has seen the tool's input - schema, so the test lists tools before calling. - """ - async with asgi_server(_echo_server(), transport="http") as running_server: - async with running_server.client() as client: - await client.list_tools() - result = await client.call_tool("echo_headers", {"tenant": "beta-corp"}) - - headers = result.data - assert headers["mcp-param-tenant"] == "beta-corp" - - -async def test_routing_headers_survive_host_origin_protection(): - """The Host/Origin request guard does not strip the routing headers.""" - async with asgi_server( - _echo_server(), - transport="http", - host_origin_protection=True, - allowed_hosts=["*"], - allowed_origins=["*"], - ) as running_server: - async with running_server.client() as client: - await client.list_tools() - result = await client.call_tool("echo_headers", {"tenant": "gamma"}) - - headers = result.data - assert headers["mcp-method"] == "tools/call" - assert headers["mcp-name"] == "echo_headers" - assert headers["mcp-param-tenant"] == "gamma" - - -async def test_x_mcp_header_annotation_survives_schema_generation(): - """FastMCP preserves `x-mcp-header` in a tool's advertised input schema. - - This is the annotation the SDK client reads to decide which arguments to - mirror into `Mcp-Param-*` headers, so it must reach the wire unchanged. - """ - server = _echo_server() - tools = await server._list_tools() - (tool,) = [t for t in tools if t.name == "echo_headers"] - assert tool.parameters["properties"]["tenant"]["x-mcp-header"] == "Tenant" diff --git a/tests/server/http/test_session_idle_timeout.py b/tests/server/http/test_session_idle_timeout.py index f7adad149..6b3558723 100644 --- a/tests/server/http/test_session_idle_timeout.py +++ b/tests/server/http/test_session_idle_timeout.py @@ -43,7 +43,7 @@ def test_idle_session_is_terminated_after_timeout(): app = create_streamable_http_app( server=server, streamable_http_path="/mcp", - session_idle_timeout=0.1, + session_idle_timeout=0.2, ) with TestClient(app, base_url="http://127.0.0.1") as client: @@ -58,15 +58,11 @@ def test_idle_session_is_terminated_after_timeout(): # Wait past the idle deadline; the SDK's idle cancel scope fires and # removes the session from the active instances. Poll to stay fast. - # The idle timeout itself is driven by anyio's event-loop clock - # inside the SDK (not a mockable Python-level time source), so this - # remains a real wait; the timeout and poll interval are kept as - # small as reliably possible. deadline = time.monotonic() + 3.0 while time.monotonic() < deadline: if session_id not in sm._server_instances: break - time.sleep(0.02) + time.sleep(0.05) assert session_id not in sm._server_instances diff --git a/tests/server/http/test_stale_access_token.py b/tests/server/http/test_stale_access_token.py index 41aa450d8..c2cf19180 100644 --- a/tests/server/http/test_stale_access_token.py +++ b/tests/server/http/test_stale_access_token.py @@ -11,7 +11,6 @@ from unittest.mock import MagicMock from mcp.server.auth.middleware.auth_context import auth_context_var from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken as SDKAccessToken from starlette.requests import Request from fastmcp.server.auth import AccessToken @@ -167,55 +166,3 @@ class TestStaleAccessToken: finally: auth_context_var.reset(auth_token) fastmcp_request_ctx.reset(request_token) - - -class TestAccessTokenSubjectConversion: - """Regression tests for issue #4266. - - When a ``TokenVerifier`` returns the SDK's own ``AccessToken`` (or any - object that isn't FastMCP's ``AccessToken`` subclass), ``get_access_token()`` - converts it via ``model_dump()``. That conversion previously dropped the - ``subject`` field entirely. - """ - - def test_subject_carried_over_when_converting_sdk_access_token(self): - """A raw SDK AccessToken with a populated subject survives conversion.""" - sdk_token = SDKAccessToken( - token="sdk-token", - client_id="test-client", - scopes=["read"], - subject="user-99", - ) - user = AuthenticatedUser(sdk_token) - scope = {"type": "http", "user": user, "auth": MagicMock()} - mock_request = Request(scope) - - request_token = fastmcp_request_ctx.set(_make_ctx(mock_request)) - try: - result = get_access_token() - finally: - fastmcp_request_ctx.reset(request_token) - - assert result is not None - assert not isinstance(sdk_token, AccessToken) # confirms conversion ran - assert result.subject == "user-99" - - def test_subject_none_when_converting_sdk_access_token_without_subject(self): - """A raw SDK AccessToken with no subject converts cleanly to subject=None.""" - sdk_token = SDKAccessToken( - token="sdk-token-no-subject", - client_id="test-client", - scopes=["read"], - ) - user = AuthenticatedUser(sdk_token) - scope = {"type": "http", "user": user, "auth": MagicMock()} - mock_request = Request(scope) - - request_token = fastmcp_request_ctx.set(_make_ctx(mock_request)) - try: - result = get_access_token() - finally: - fastmcp_request_ctx.reset(request_token) - - assert result is not None - assert result.subject is None diff --git a/tests/server/http/test_startup_imports.py b/tests/server/http/test_startup_imports.py deleted file mode 100644 index addcd99b7..000000000 --- a/tests/server/http/test_startup_imports.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Fresh-interpreter import guards for the default HTTP server path.""" - -from __future__ import annotations - -import subprocess -import sys -import textwrap - -import pytest - - -@pytest.mark.subprocess_heavy -def test_root_import_does_not_load_mcp_sdk() -> None: - script = textwrap.dedent( - """ - import sys - - import fastmcp - - assert fastmcp.settings is not None - assert "mcp" not in sys.modules - assert "fastmcp.exceptions" not in sys.modules - """ - ) - - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - - -@pytest.mark.subprocess_heavy -def test_server_import_does_not_load_cli() -> None: - script = textwrap.dedent( - """ - import sys - - from fastmcp import FastMCP - - assert FastMCP is not None - assert "fastmcp.utilities.cli" not in sys.modules - """ - ) - - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - - -@pytest.mark.subprocess_heavy -def test_default_http_app_does_not_load_opt_in_integrations() -> None: - script = textwrap.dedent( - """ - import sys - - from fastmcp import FastMCP - - server = FastMCP("HTTP import guard") - - @server.tool - def echo(value: str) -> str: - return value - - app = server.http_app(transport="http", stateless_http=True) - assert app is not None - - forbidden = ( - "fastmcp.server.event_store", - "griffe", - "jsonref", - "key_value", - "prefab_ui", - ) - loaded = [ - name - for name in sys.modules - if any(name == root or name.startswith(f"{root}.") for root in forbidden) - ] - assert not loaded, loaded - """ - ) - - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - - -@pytest.mark.subprocess_heavy -def test_fastmcp_server_import_does_not_load_context() -> None: - script = textwrap.dedent( - """ - import sys - - from fastmcp import FastMCP - - assert FastMCP is not None - assert "fastmcp.server.context" not in sys.modules - """ - ) - - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index fec5424c1..88909836e 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -3,9 +3,7 @@ import sys import tempfile import warnings -from collections.abc import Sequence from pathlib import Path -from typing import Any from unittest.mock import AsyncMock, MagicMock import mcp_types @@ -34,7 +32,7 @@ from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.resources.base import Resource from fastmcp.server.middleware.caching import ( ANONYMOUS_AUTH_KEY, - CacheableToolResult, + CachableToolResult, CallToolSettings, ResponseCachingMiddleware, ResponseCachingStatistics, @@ -42,11 +40,7 @@ from fastmcp.server.middleware.caching import ( _make_get_prompt_cache_key, _make_read_resource_cache_key, ) -from fastmcp.server.middleware.middleware import ( - CallNext, - Middleware, - MiddlewareContext, -) +from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.tasks import TaskConfig @@ -290,42 +284,6 @@ class TestResponseCachingMiddleware: ) assert middleware1._matches_tool_cache_settings(tool_name=tool_name) is result - @pytest.mark.parametrize( - ("first", "second"), - [ - ({"a": 5, "b": 3}, {"b": 3, "a": 5}), - ({"q": {"x": 1, "y": 2}}, {"q": {"y": 2, "x": 1}}), - ({"items": [{"x": 1, "y": 2}]}, {"items": [{"y": 2, "x": 1}]}), - ], - ids=["top level", "nested dict", "dict inside a list"], - ) - def test_call_tool_cache_key_ignores_argument_order( - self, first: dict[str, Any], second: dict[str, Any] - ): - assert _make_call_tool_cache_key( - mcp_types.CallToolRequestParams(name="tool", arguments=first) - ) == _make_call_tool_cache_key( - mcp_types.CallToolRequestParams(name="tool", arguments=second) - ) - - def test_get_prompt_cache_key_ignores_argument_order(self): - assert _make_get_prompt_cache_key( - mcp_types.GetPromptRequestParams( - name="prompt", arguments={"a": "5", "b": "3"} - ) - ) == _make_get_prompt_cache_key( - mcp_types.GetPromptRequestParams( - name="prompt", arguments={"b": "3", "a": "5"} - ) - ) - - def test_call_tool_cache_key_distinguishes_arguments(self): - assert _make_call_tool_cache_key( - mcp_types.CallToolRequestParams(name="tool", arguments={"a": 5, "b": 3}) - ) != _make_call_tool_cache_key( - mcp_types.CallToolRequestParams(name="tool", arguments={"a": 3, "b": 5}) - ) - @pytest.mark.skipif( sys.platform == "win32", @@ -397,18 +355,9 @@ class TestResponseCachingMiddlewareIntegration: async def test_list_operations_preserve_component_metadata(self): """Base component fields should survive conversion through the cache.""" - from fastmcp.server.extensions import ServerExtension - from fastmcp.utilities.tasks import TASKS_EXTENSION_ID - - class _StubTasksExtension(ServerExtension): - identifier = TASKS_EXTENSION_ID - icon = mcp_types.Icon(src="https://example.com/component.png") mcp = FastMCP("MetadataServer") mcp.add_middleware(ResponseCachingMiddleware()) - # A task-enabled tool requires the tasks extension to serve; register a - # stub so the metadata (execution.task_support) can be verified end-to-end. - mcp.add_extension(_StubTasksExtension()) @mcp.tool(icons=[icon], task=TaskConfig(mode="optional")) async def greet() -> str: @@ -432,9 +381,7 @@ class TestResponseCachingMiddlewareIntegration: assert not hasattr(cached_resources[0], "fn") assert not hasattr(cached_prompts[0], "fn") - # Pinned to legacy: the tool's `execution.task_support` (SEP-1686) is - # advertised in the handshake-era tool listing; the modern listing omits it. - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: for _ in range(2): tools = await client.list_tools() resources = await client.list_resources() @@ -466,18 +413,6 @@ class TestResponseCachingMiddlewareIntegration: ) assert call_tool_result_one == call_tool_result_two - async def test_call_tool_with_reordered_arguments_hits_cache( - self, - caching_server: FastMCP, - tracking_calculator: TrackingCalculator, - ): - async with Client[FastMCPTransport](transport=caching_server) as client: - first = await client.call_tool("add", {"a": 5, "b": 3}) - second = await client.call_tool("add", {"b": 3, "a": 5}) - - assert first == second - assert tracking_calculator.add_calls == 1 - async def test_call_tool_very_large_value( self, caching_server: FastMCP, @@ -636,7 +571,7 @@ class TestResponseCachingMiddlewareIntegration: ) -class TestCacheableToolResult: +class TestCachableToolResult: def test_wrap_and_unwrap(self): tool_result = ToolResult( "unstructured content", @@ -644,7 +579,7 @@ class TestCacheableToolResult: meta={"meta": "data"}, ) - cached_tool_result = CacheableToolResult.wrap(tool_result).unwrap() + cached_tool_result = CachableToolResult.wrap(tool_result).unwrap() assert cached_tool_result.content == tool_result.content assert cached_tool_result.structured_content == tool_result.structured_content @@ -653,56 +588,11 @@ class TestCacheableToolResult: def test_wrap_and_unwrap_preserves_is_error(self): tool_result = ToolResult("boom", is_error=True) - cached_tool_result = CacheableToolResult.wrap(tool_result).unwrap() + cached_tool_result = CachableToolResult.wrap(tool_result).unwrap() assert cached_tool_result.is_error is True -class TestErrorResultsAreNotCached: - """Regression tests for issue #4395: an error result was cached for the full - TTL, so a transient failure permanently shadowed the tool until it expired.""" - - async def test_error_result_is_not_cached(self): - mcp = FastMCP("ErrorCachingTestServer") - mcp.add_middleware(ResponseCachingMiddleware(cache_storage=MemoryStore())) - - call_count = 0 - - @mcp.tool - def flakey() -> ToolResult: - nonlocal call_count - call_count += 1 - if call_count == 1: - return ToolResult("upstream 503", is_error=True) - return ToolResult("recovered") - - async with Client(mcp) as client: - first = await client.call_tool("flakey", {}, raise_on_error=False) - assert first.is_error is True - - # The tool must actually run again rather than replay the error. - second = await client.call_tool("flakey", {}, raise_on_error=False) - assert second.is_error is False - assert call_count == 2 - - async def test_successful_result_is_still_cached(self): - mcp = FastMCP("SuccessCachingTestServer") - mcp.add_middleware(ResponseCachingMiddleware(cache_storage=MemoryStore())) - - call_count = 0 - - @mcp.tool - def stable() -> str: - nonlocal call_count - call_count += 1 - return "ok" - - async with Client(mcp) as client: - await client.call_tool("stable", {}) - await client.call_tool("stable", {}) - assert call_count == 1 - - class TestCachingWithImportedServerPrefixes: """Test that caching preserves prefixes from imported servers. @@ -1001,83 +891,3 @@ class TestAuthAwareCaching: assert {p.name for p in prompts} == {"public_prompt"} finally: auth_context_var.reset(tok) - - -class CountingDownstream(Middleware): - """Counts the list calls that get past the caching middleware, i.e. cache misses.""" - - def __init__(self) -> None: - self.list_calls = 0 - - async def on_list_tools( - self, - context: MiddlewareContext[mcp_types.ListToolsRequest], - call_next: CallNext[mcp_types.ListToolsRequest, Sequence[Tool]], - ) -> Sequence[Tool]: - self.list_calls += 1 - return await call_next(context) - - async def on_list_resources( - self, - context: MiddlewareContext[mcp_types.ListResourcesRequest], - call_next: CallNext[mcp_types.ListResourcesRequest, Sequence[Resource]], - ) -> Sequence[Resource]: - self.list_calls += 1 - return await call_next(context) - - async def on_list_prompts( - self, - context: MiddlewareContext[mcp_types.ListPromptsRequest], - call_next: CallNext[mcp_types.ListPromptsRequest, Sequence[Prompt]], - ) -> Sequence[Prompt]: - self.list_calls += 1 - return await call_next(context) - - -class TestEmptyListCaching: - """An empty list is a cached result, not a cache miss. - - Regression tests for issue #4733: the list hooks tested the cached value for - truthiness, so a server - or a per-user filtered view - with nothing to list - re-ran the listing on every single request and never served a cache hit. - """ - - @pytest.mark.parametrize("operation", ["tools", "resources", "prompts"]) - async def test_empty_list_is_served_from_cache(self, operation: str): - counter = CountingDownstream() - mcp_server = FastMCP("test", middleware=[ResponseCachingMiddleware(), counter]) - - list_operation = getattr(mcp_server, f"list_{operation}") - for _ in range(3): - assert len(await list_operation()) == 0 - - assert counter.list_calls == 1 - - async def test_empty_filtered_view_is_served_from_cache(self): - from mcp.server.auth.middleware.auth_context import auth_context_var - from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser - - from fastmcp.server.auth import AccessToken, require_scopes - - counter = CountingDownstream() - mcp_server = FastMCP("test", middleware=[ResponseCachingMiddleware(), counter]) - - @mcp_server.tool(auth=require_scopes("admin")) - def admin_only() -> str: - return "ok" - - token = AccessToken( - token="token-read", - client_id="test-client", - scopes=["read"], - expires_at=None, - claims={}, - ) - tok = auth_context_var.set(AuthenticatedUser(token)) - try: - for _ in range(3): - assert len(await mcp_server.list_tools()) == 0 - finally: - auth_context_var.reset(tok) - - assert counter.list_calls == 1 diff --git a/tests/server/middleware/test_caching_guards.py b/tests/server/middleware/test_caching_guards.py deleted file mode 100644 index e552dc49e..000000000 --- a/tests/server/middleware/test_caching_guards.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Response caching around multi-round-trip asks (SEP-2322). - -A guard component answers a call by *returning* an `InputRequiredResult` — a -request for client input rather than a final answer. Two things follow for -`ResponseCachingMiddleware`, and they apply equally to tools, prompts, and -resources: - -- An ask must never be stored. It carries no content of its own, so caching one - writes an empty result, and every later caller is served that emptiness - instead of being asked the question. -- A continuation leg must bypass the cache entirely. Cache keys are built from - the component's identity and arguments alone, so a continuation shares its key - with a fresh call: reading could hand this leg a prior flow's final answer, and - writing would hand a later fresh caller *this* flow's answer, skipping the - questions altogether. -""" - -import mcp_types - -from fastmcp import Context, FastMCP -from fastmcp.client.client import Client -from fastmcp.client.elicitation import ElicitResult -from fastmcp.server.middleware.caching import ResponseCachingMiddleware - - -def _ask() -> mcp_types.InputRequiredResult: - """The single-question ask every guard in this module returns.""" - params = mcp_types.ElicitRequestFormParams( - message="Which quarter?", - requested_schema={ - "type": "object", - "properties": {"q": {"type": "string"}}, - "required": ["q"], - }, - ) - request = mcp_types.ElicitRequest(method="elicitation/create", params=params) - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={"q": request}, - ) - - -def _answer(responses: mcp_types.InputResponses) -> str: - """The accepted value for the question `_ask` poses.""" - result = responses["q"] - assert isinstance(result, mcp_types.ElicitResult) - assert result.content is not None - return str(result.content["q"]) - - -async def _handler(message, response_type, params, ctx): - """An elicitation handler that always answers "Q3".""" - return ElicitResult(action="accept", content=response_type(q="Q3")) - - -def cached_guard_server() -> FastMCP: - """A caching server whose tool, prompt, and resource are all guards.""" - mcp = FastMCP("cached-guards") - mcp.add_middleware(ResponseCachingMiddleware()) - - @mcp.tool - async def summarize_tool(ctx: Context) -> str | mcp_types.InputRequiredResult: - if ctx.input_responses is None: - return _ask() - return f"Summary for {_answer(ctx.input_responses)}" - - @mcp.prompt - async def summarize(ctx: Context) -> str | mcp_types.InputRequiredResult: - if ctx.input_responses is None: - return _ask() - return f"Summary for {_answer(ctx.input_responses)}" - - @mcp.resource("report://x") - async def report(ctx: Context) -> str | mcp_types.InputRequiredResult: - if ctx.input_responses is None: - return _ask() - return f"Report for {_answer(ctx.input_responses)}" - - return mcp - - -def guard_client() -> Client: - """A client that answers each round automatically.""" - return Client(cached_guard_server(), mode="auto", elicitation_handler=_handler) - - -class TestGuardsCompleteUnderCaching: - """Each component type drives its loop to a real answer with caching on.""" - - async def test_tool(self): - async with guard_client() as client: - result = await client.call_tool("summarize_tool", {}) - - assert result.data == "Summary for Q3" - - async def test_prompt(self): - async with guard_client() as client: - result = await client.get_prompt("summarize") - - assert result.messages[0].content.text == "Summary for Q3" - - async def test_resource(self): - async with guard_client() as client: - result = await client.read_resource("report://x") - - assert result[0].text == "Report for Q3" - - -class TestAsksAreNotCached: - """A stored ask would poison every later caller.""" - - async def test_second_fresh_flow_is_asked_again(self): - """A second fresh flow must be asked the same question. - - Serving it a cached final answer would skip the component's own - per-round logic — it would receive an answer it never supplied input for. - """ - async with guard_client() as client: - first = await client.get_prompt("summarize") - second = await client.get_prompt("summarize") - - assert first.messages[0].content.text == "Summary for Q3" - assert second.messages[0].content.text == "Summary for Q3" diff --git a/tests/server/middleware/test_discovery_middleware.py b/tests/server/middleware/test_discovery_middleware.py deleted file mode 100644 index 6b8bd8f09..000000000 --- a/tests/server/middleware/test_discovery_middleware.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for typed middleware support during modern discovery.""" - -from typing import Any - -import mcp_types -from mcp_types.version import LATEST_MODERN_VERSION - -from fastmcp import Client, FastMCP -from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext - - -async def test_on_discover_receives_and_transforms_typed_result(): - class DiscoveryMiddleware(Middleware): - def __init__(self) -> None: - self.request: mcp_types.DiscoverRequest | None = None - self.result: mcp_types.DiscoverResult | None = None - - async def on_discover( - self, - context: MiddlewareContext[mcp_types.DiscoverRequest], - call_next: CallNext[ - mcp_types.DiscoverRequest, - mcp_types.DiscoverResult | dict[str, Any], - ], - ) -> mcp_types.DiscoverResult | dict[str, Any]: - self.request = context.message - result = await call_next(context) - assert isinstance(result, mcp_types.DiscoverResult) - self.result = result - return result.model_copy(update={"instructions": "discovered"}) - - middleware = DiscoveryMiddleware() - server = FastMCP("typed-discovery", middleware=[middleware]) - - async with Client(server, mode="auto") as client: - assert client.instructions == "discovered" - - assert isinstance(middleware.request, mcp_types.DiscoverRequest) - assert isinstance(middleware.result, mcp_types.DiscoverResult) - - -async def test_on_discover_forwards_modified_params(): - modified = False - server = FastMCP("modified-discovery") - default_handler = server._mcp_server._handle_discover - - async def capture_params(ctx, params): - nonlocal modified - assert params is not None - assert params.meta is not None - modified = params.meta["com.example/modified"] is True - return await default_handler(ctx, params) - - server._mcp_server.add_request_handler( - "server/discover", mcp_types.RequestParams, capture_params - ) - - class ModifyParams(Middleware): - async def on_discover(self, context, call_next): - assert context.message.params is not None - assert context.message.params.meta is not None - context.message.params = mcp_types.RequestParams( - meta={ - **context.message.params.meta, - "com.example/modified": True, - } - ) - return await call_next(context) - - server.add_middleware(ModifyParams()) - - async with Client(server, mode="auto"): - pass - - assert modified - - -async def test_on_discover_preserves_extension_owned_result(): - extension_result = { - "resultType": "com.example/custom", - "payload": {"enabled": True}, - } - - async def custom_discover(_ctx, _params): - return extension_result - - class ObserveExtension(Middleware): - def __init__(self) -> None: - self.result: mcp_types.DiscoverResult | dict[str, Any] | None = None - - async def on_discover(self, context, call_next): - self.result = await call_next(context) - return self.result - - middleware = ObserveExtension() - server = FastMCP("extension-discovery", middleware=[middleware]) - server._mcp_server.add_request_handler( - "server/discover", mcp_types.RequestParams, custom_discover - ) - - async with Client(server, mode=LATEST_MODERN_VERSION) as client: - result = await client.session.send_discover(LATEST_MODERN_VERSION) - - assert isinstance(result, dict) - assert result["resultType"] == "com.example/custom" - assert result["payload"] == {"enabled": True} - assert isinstance(middleware.result, dict) - assert middleware.result["payload"] == {"enabled": True} diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py index a42ee79af..3a077b367 100644 --- a/tests/server/middleware/test_initialization_middleware.py +++ b/tests/server/middleware/test_initialization_middleware.py @@ -1,14 +1,4 @@ -"""Tests for middleware support during initialization. - -`on_initialize` only fires for the `initialize` handshake, which is unique to -the older protocol version; the modern version connects without it, so a -default client never triggers this hook. Most tests below pin `mode="legacy"` -for that reason. `test_session_state_persists_across_tool_calls` pins for a -different reason: it exercises `ctx.set_state`/`get_state` persisting across -multiple tool calls in the same client session, which requires the -handshake-era's persistent session (see `test_session_visibility.py` for the -same distinction applied to a different feature). -""" +"""Tests for middleware support during initialization.""" from collections.abc import Sequence from typing import Any @@ -133,7 +123,7 @@ async def test_simple_initialization_hook(): server.add_middleware(middleware) # Connect client - async with Client(server, mode="legacy"): + async with Client(server): # Middleware should have been called assert middleware.called is True, "on_initialize was not called" @@ -149,7 +139,7 @@ async def test_middleware_receives_initialization(): return f"Result: {x}" # Connect client - async with Client(server, mode="legacy") as client: + async with Client(server) as client: # Middleware should have been called during initialization assert middleware.initialized is True @@ -170,7 +160,7 @@ async def test_client_detection_middleware(): return "example" # Connect with a client - async with Client(server, mode="legacy") as client: + async with Client(server) as client: # Middleware should have been called during initialization assert middleware.initialization_called is True assert middleware.is_test_client is True @@ -200,7 +190,7 @@ async def test_multiple_middleware_initialization(): def test_tool() -> str: return "test" - async with Client(server, mode="legacy") as client: + async with Client(server) as client: # Both middleware should have processed initialization assert init_mw.initialized is True assert detect_mw.initialization_called is True @@ -251,7 +241,7 @@ async def test_session_state_persists_across_tool_calls(): def test_tool() -> str: return "success" - async with Client(server, mode="legacy") as client: + async with Client(server) as client: # First call - state should be None initially result = await client.call_tool("test_tool", {}) assert isinstance(result.content[0], TextContent) @@ -297,7 +287,7 @@ async def test_middleware_can_access_initialize_result(): middleware = ResponseCapturingMiddleware() server.add_middleware(middleware) - async with Client(server, mode="legacy"): + async with Client(server): # Middleware should have captured the InitializeResult assert middleware.initialize_result is not None assert isinstance(middleware.initialize_result, mt.InitializeResult) @@ -325,7 +315,7 @@ async def test_middleware_mcp_error_during_initialization(): server.add_middleware(ErrorThrowingMiddleware()) with pytest.raises(MCPError) as exc_info: - async with Client(server, mode="legacy"): + async with Client(server): pass assert exc_info.value.error.message == "Invalid initialization parameters" @@ -347,7 +337,7 @@ async def test_middleware_mcp_error_before_call_next(): server.add_middleware(EarlyErrorMiddleware()) with pytest.raises(MCPError) as exc_info: - async with Client(server, mode="legacy"): + async with Client(server): pass assert exc_info.value.error.message == "Request validation failed" @@ -380,7 +370,7 @@ async def test_middleware_mcp_error_after_call_next(): server.add_middleware(middleware) # Error is logged but not re-raised to prevent duplicate response - async with Client(server, mode="legacy"): + async with Client(server): pass assert middleware.error_raised is True @@ -391,8 +381,9 @@ async def test_state_isolation_between_streamable_http_clients(): Each client should have its own session ID and isolated state. """ + from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.context import Context - from fastmcp.utilities.tests import asgi_server + from fastmcp.utilities.tests import run_server_async server = FastMCP("TestServer") @@ -407,11 +398,12 @@ async def test_state_isolation_between_streamable_http_clients(): "session_id": ctx.session_id, } - async with asgi_server(server, transport="streamable-http") as running_server: + async with run_server_async(server, transport="streamable-http") as url: import json # Client 1 stores its value - async with running_server.client() as client1: + transport1 = StreamableHttpTransport(url=url) + async with Client(transport=transport1) as client1: result1 = await client1.call_tool( "store_and_read", {"value": "client1-value"} ) @@ -421,7 +413,8 @@ async def test_state_isolation_between_streamable_http_clients(): session_id_1 = data1["session_id"] # Client 2 should have completely isolated state - async with running_server.client() as client2: + transport2 = StreamableHttpTransport(url=url) + async with Client(transport=transport2) as client2: result2 = await client2.call_tool( "store_and_read", {"value": "client2-value"} ) diff --git a/tests/server/middleware/test_message_visibility.py b/tests/server/middleware/test_message_visibility.py deleted file mode 100644 index 5c2465637..000000000 --- a/tests/server/middleware/test_message_visibility.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Middleware message visibility (v4 D3, the middleware hybrid rebase). - -Dispatch begins in the SDK's middleware layer, so ``on_message``/``on_request``/ -``on_notification`` observe *every* inbound message — including the ones that -never reach a FastMCP handler (notifications, cancellations, and -malformed/unroutable requests) and were therefore invisible to FastMCP -middleware before. The typed per-method hooks keep firing exactly once, interior, -where ``call_next`` yields the typed component result. -""" - -from typing import Any - -import mcp_types -import pytest -from mcp.shared.dispatcher import CallOptions -from mcp.shared.exceptions import MCPError -from mcp_types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult - -from fastmcp import Client, FastMCP -from fastmcp.server.context import Context -from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.base import InputRequiredToolResult - - -class HookRecorder(Middleware): - """Records ``(hook, method)`` before delegating, so a hook is captured even - when ``call_next`` raises (a pre-handler failure).""" - - def __init__(self) -> None: - self.records: list[tuple[str, str | None]] = [] - - async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any: - self.records.append(("on_message", context.method)) - return await call_next(context) - - async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: - self.records.append(("on_request", context.method)) - return await call_next(context) - - async def on_notification( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - self.records.append(("on_notification", context.method)) - return await call_next(context) - - async def on_call_tool( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - self.records.append(("on_call_tool", context.method)) - return await call_next(context) - - async def on_list_tools( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - self.records.append(("on_list_tools", context.method)) - return await call_next(context) - - -def _adder() -> FastMCP: - server = FastMCP("AdderServer") - - @server.tool - def add(a: int, b: int) -> int: - return a + b - - return server - - -async def _raw_request( - client: Client, method: str, params: dict[str, Any] -) -> dict[str, Any]: - """Send a bare JSON-RPC request through the dispatcher, bypassing the - typed `send_request` that normally stamps the outgoing envelope. - - The modern protocol version requires every request's `params._meta` to - carry the protocol version, client info, and client capabilities (there is - no handshake to establish them once, up front), so a raw request built by - hand must stamp them the same way `send_request` would or the server - rejects the envelope before dispatch ever sees it. - """ - data: dict[str, Any] = {"method": method, "params": params} - opts: CallOptions = {} - client.session._stamp(data, opts) - return await client.session._dispatcher.send_raw_request( - method, data.get("params"), opts - ) - - -class TestNotificationVisibility: - async def test_client_cancelled_notification_reaches_on_message(self): - """A ``notifications/cancelled`` from the client is observed by - ``on_message`` and ``on_notification`` — it never reaches a FastMCP - handler, so before the rebase it was invisible to middleware.""" - server = _adder() - recorder = HookRecorder() - server.add_middleware(recorder) - - async with Client(server) as client: - await client.session.send_notification( - mcp_types.CancelledNotification( - params=mcp_types.CancelledNotificationParams( - request_id="never-issued" - ) - ) - ) - # Round-trip on the same connection so the notification is dispatched - # before we assert (in-order delivery). - await client.call_tool("add", {"a": 1, "b": 2}) - - assert ("on_message", "notifications/cancelled") in recorder.records - assert ("on_notification", "notifications/cancelled") in recorder.records - - async def test_client_progress_notification_reaches_on_message(self): - """A generic client notification is observed by ``on_message``.""" - server = _adder() - recorder = HookRecorder() - server.add_middleware(recorder) - - async with Client(server) as client: - await client.session.send_notification( - mcp_types.ProgressNotification( - params=mcp_types.ProgressNotificationParams( - progress_token="tok", progress=1.0 - ) - ) - ) - await client.call_tool("add", {"a": 1, "b": 2}) - - assert ("on_message", "notifications/progress") in recorder.records - - -class TestUnroutableAndMalformed: - async def test_unroutable_method_observed_by_on_message(self): - """An unknown method fails routing before any handler; the root dispatch still - runs ``on_message``/``on_request`` around the failure.""" - server = _adder() - recorder = HookRecorder() - server.add_middleware(recorder) - - async with Client(server) as client: - with pytest.raises(MCPError): - await _raw_request(client, "does/not/exist", {}) - - assert ("on_message", "does/not/exist") in recorder.records - assert ("on_request", "does/not/exist") in recorder.records - - async def test_malformed_component_params_observed_by_on_message(self): - """A ``tools/call`` with malformed params fails validation before the - interior handler runs, so no typed hook fires — but the root dispatch observes the - failure through ``on_message``, and ``on_call_tool`` does not fire.""" - server = _adder() - recorder = HookRecorder() - server.add_middleware(recorder) - - async with Client(server) as client: - with pytest.raises(MCPError): - await _raw_request(client, "tools/call", {"not_a_valid": "param"}) - - assert ("on_message", "tools/call") in recorder.records - assert ("on_call_tool", "tools/call") not in recorder.records - - async def test_malformed_discover_params_observed_by_generic_hooks(self): - server = _adder() - recorder = HookRecorder() - server.add_middleware(recorder) - - async with Client(server) as client: - recorder.records.clear() - with pytest.raises(MCPError): - await _raw_request( - client, - "server/discover", - {"_meta": {"progressToken": []}}, - ) - - assert ("on_message", "server/discover") in recorder.records - assert ("on_request", "server/discover") in recorder.records - - -class TestSingleFire: - async def test_each_hook_fires_once_per_component_call(self): - """One ``tools/call`` fires ``on_message`` once and ``on_call_tool`` once — - the interior dispatch is the single entry for component methods; the root dispatch - does not double-run it.""" - server = _adder() - recorder = HookRecorder() - server.add_middleware(recorder) - - async with Client(server) as client: - await client.call_tool("add", {"a": 1, "b": 2}) - - on_message = [r for r in recorder.records if r == ("on_message", "tools/call")] - on_call_tool = [ - r for r in recorder.records if r == ("on_call_tool", "tools/call") - ] - assert len(on_message) == 1 - assert len(on_call_tool) == 1 - - -class TestRawMiddlewareCompatibility: - """Middleware may override ``__call__(context, call_next)`` — the documented - raw signature. The dispatch phase travels out-of-band, so that contract is - unchanged and such middleware keeps working.""" - - async def test_raw_call_override_still_works(self): - seen: list[str | None] = [] - - class RawMiddleware(Middleware): - async def __call__(self, context, call_next): - seen.append(context.method) - return await call_next(context) - - server = _adder() - server.add_middleware(RawMiddleware()) - - async with Client(server) as client: - result = await client.call_tool("add", {"a": 1, "b": 2}) - await client.session.send_notification( - mcp_types.ProgressNotification( - params=mcp_types.ProgressNotificationParams( - progress_token="tok", progress=1.0 - ) - ) - ) - await client.call_tool("add", {"a": 1, "b": 2}) - - assert result.data == 3 - # It observes both a component call and a message the root dispatch owns. - assert "tools/call" in seen - assert "notifications/progress" in seen - - -class TestMessageModification: - """The root dispatch hands middleware a copy of the raw params, so edits made - through the documented inspect/modify contract must be folded back into the - SDK context before the real dispatch runs.""" - - async def test_modified_message_reaches_sdk_dispatch(self): - """A ``logging/setLevel`` carrying an invalid level fails params - validation inside ``call_next``. Middleware that rewrites the message to - a valid level makes the request succeed — which only happens if the edit - is actually forwarded.""" - - class RewriteLevel(Middleware): - async def on_message(self, context, call_next): - if context.method == "logging/setLevel": - context.message["level"] = "debug" - return await call_next(context) - - server = _adder() - server.add_middleware(RewriteLevel()) - - # `logging/setLevel` was dropped from the method registry in the modern - # protocol version (logging is opt-in per-request via `_meta` there), - # so exercising it needs the older protocol. - async with Client(server, mode="legacy") as client: - await client.session._dispatcher.send_raw_request( - "logging/setLevel", {"level": "not-a-valid-level"}, {} - ) - - async def test_unmodified_message_dispatches_unchanged(self): - """An observation-only hook leaves dispatch untouched.""" - server = _adder() - recorder = HookRecorder() - server.add_middleware(recorder) - - # `logging/setLevel` only exists on the older protocol; see the pin - # note in `test_modified_message_reaches_sdk_dispatch` above. - async with Client(server, mode="legacy") as client: - await client.session._dispatcher.send_raw_request( - "logging/setLevel", {"level": "debug"}, {} - ) - - assert ("on_message", "logging/setLevel") in recorder.records - - async def test_method_rewrite_does_not_redirect_dispatch(self): - """Only the message is rewritable. Dispatch has already branched on the - method to decide this message has no interior handler, so honoring a - rewrite into a component method would hand it to a handler that runs the - chain again — firing the generic hooks twice for one message. The - rewrite is ignored and the invariant holds.""" - - class RewriteMethod(Middleware): - async def on_message(self, context, call_next): - if context.method == "ping": - return await call_next(context.copy(method="tools/list")) - return await call_next(context) - - server = _adder() - recorder = HookRecorder() - # Recorder outermost, so it observes the message as it arrived; the - # rewriter runs inside it. - server.add_middleware(recorder) - server.add_middleware(RewriteMethod()) - - # `ping` was removed from the modern protocol version, so this pins - # the era where it's still a real method to rewrite away from. - async with Client(server, mode="legacy") as client: - await client.session._dispatcher.send_raw_request("ping", {}, {}) - - # Had the rewrite redirected dispatch, the component handler would have - # run the chain again — a second on_message, plus an on_list_tools for a - # request that was never a tools/list. - assert [r for r in recorder.records if r == ("on_message", "ping")] == [ - ("on_message", "ping") - ] - assert not [r for r in recorder.records if r == ("on_message", "tools/list")] - assert not [r for r in recorder.records if r[0] == "on_list_tools"] - - async def test_failed_component_request_is_observed_not_retried(self): - """A component request that dies in validation reaches the hooks as a - failure. A hook cannot repair it from here: re-dispatching would run the - handler and fire the generic hooks a second time, so the failure stands - and ``on_message`` sees it exactly once.""" - - class RepairAttempt(Middleware): - async def on_message(self, context, call_next): - if context.method == "tools/call": - context.message["name"] = "add" - context.message["arguments"] = {"a": 1, "b": 2} - return await call_next(context) - - server = _adder() - recorder = HookRecorder() - server.add_middleware(RepairAttempt()) - server.add_middleware(recorder) - - async with Client(server) as client: - with pytest.raises(MCPError): - await _raw_request(client, "tools/call", {"not_a_valid": "param"}) - - calls = [r for r in recorder.records if r == ("on_message", "tools/call")] - assert len(calls) == 1 - assert ("on_call_tool", "tools/call") not in recorder.records - - -def _guard_server() -> FastMCP: - server = FastMCP("Guard") - - @server.tool - async def guard(ctx: Context) -> str | InputRequiredResult: - if ctx.input_responses is None: - request = ElicitRequest( - method="elicitation/create", - params=ElicitRequestFormParams( - message="Your name?", - requested_schema={ - "type": "object", - "properties": {"name": {"type": "string"}}, - "required": ["name"], - }, - ), - ) - return InputRequiredResult( - result_type="input_required", - input_requests={"name": request}, - request_state=None, - ) - return "done" - - return server - - -class TestAskVisibility: - async def test_ask_is_the_observed_result_of_a_guard_leg(self): - """Each MRTR leg is a complete request→response cycle: a guard tool's ask - is the full, legitimate result of that leg. A component hook's - ``call_next`` returns it as an ordinary value — an - ``InputRequiredToolResult`` (a ``ToolResult`` subclass) — so the hook - completes normally and can identify the ask by ``isinstance``.""" - - class AskProbe(Middleware): - def __init__(self) -> None: - self.entered = 0 - self.results: list[Any] = [] - - async def on_call_tool( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - self.entered += 1 - result = await call_next(context) - self.results.append(result) - return result - - server = _guard_server() - probe = AskProbe() - server.add_middleware(probe) - - async with Client(server, mode="auto") as client: - result = await client.session.call_tool( - "guard", {}, allow_input_required=True - ) - - assert isinstance(result, InputRequiredResult) - assert probe.entered == 1 - # The hook completed and observed the ask as the leg's result value. - assert len(probe.results) == 1 - assert isinstance(probe.results[0], InputRequiredToolResult) - - async def test_hooks_fire_once_per_round_across_a_continuation(self): - """The fires-once invariant holds across a continuation — the one place - root dispatch and MRTR genuinely meet. Each round is its own complete - request→response cycle, so answering the ask runs the chain a second - time in full rather than double-firing on either round.""" - server = _guard_server() - recorder = HookRecorder() - server.add_middleware(recorder) - - async with Client(server, mode="auto") as client: - ask = await client.session.call_tool("guard", {}, allow_input_required=True) - assert isinstance(ask, InputRequiredResult) - - answered = await client.session.call_tool( - "guard", - {}, - input_responses={ - "name": {"action": "accept", "content": {"name": "Ada"}} - }, - request_state=ask.request_state, - allow_input_required=True, - ) - - assert isinstance(answered, mcp_types.CallToolResult) - # Two rounds — the ask and the answer — and exactly one chain per round. - on_message = [r for r in recorder.records if r == ("on_message", "tools/call")] - on_call_tool = [ - r for r in recorder.records if r == ("on_call_tool", "tools/call") - ] - assert len(on_message) == 2 - assert len(on_call_tool) == 2 - - -class TestSchedulingProbe: - async def test_trivial_noop(self): - """Temporary probe: does merely adding a 7th test destabilize the run?""" - assert True diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 715413017..f58736437 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -147,6 +147,10 @@ def mcp_server(recording_middleware): async def log_tool(context: Context) -> None: await context.info(message="test log") + @mcp.tool + async def sample_tool(context: Context) -> None: + await context.sample("hello") + mcp.add_middleware(recording_middleware) # Register a progress notification handler (v2 API: (ctx, params)). @@ -172,10 +176,7 @@ class TestMiddlewareHooks: async with Client(mcp_server) as client: await client.call_tool("add", {"a": 1, "b": 2}) - # The floor is lower than a legacy connection's 11: the modern - # `server/discover` negotiation fires 2 generic hooks, vs. 5 for the - # older `initialize` request plus its `notifications/initialized`. - assert recording_middleware.assert_called(at_least=8) + assert recording_middleware.assert_called(at_least=9) assert recording_middleware.assert_called(method="tools/call", at_least=3) assert recording_middleware.assert_called(hook="on_message", at_least=1) assert recording_middleware.assert_called(hook="on_request", at_least=1) @@ -298,8 +299,7 @@ class TestMiddlewareHooks: async def test_initialize( self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware ): - # `ping` only exists on the older protocol, so this pins that era. - async with Client(mcp_server, mode="legacy") as client: + async with Client(mcp_server) as client: await client.ping() assert recording_middleware.assert_called(at_least=1) diff --git a/tests/server/middleware/test_middleware_nested.py b/tests/server/middleware/test_middleware_nested.py index 374a07cc8..cc2eea6c0 100644 --- a/tests/server/middleware/test_middleware_nested.py +++ b/tests/server/middleware/test_middleware_nested.py @@ -149,6 +149,10 @@ def mcp_server(recording_middleware): async def log_tool(context: Context) -> None: await context.info(message="test log") + @mcp.tool + async def sample_tool(context: Context) -> None: + await context.sample("hello") + mcp.add_middleware(recording_middleware) # Register a progress notification handler (v2 API: (ctx, params)). @@ -201,6 +205,10 @@ class TestNestedMiddlewareHooks: async def log_tool(context: Context) -> None: await context.info(message="test log") + @mcp.tool + async def sample_tool(context: Context) -> None: + await context.sample("hello") + mcp.add_middleware(nested_middleware) return mcp @@ -502,7 +510,7 @@ class TestProxyServer: async with Client(proxy_server) as client: await client.list_tools() - assert TAGS == [{"add-tool"}, set(), set()] + assert TAGS == [{"add-tool"}, set(), set(), set()] class TestToolCallDenial: diff --git a/tests/server/middleware/test_ping.py b/tests/server/middleware/test_ping.py index fdf3a7591..b35616fc1 100644 --- a/tests/server/middleware/test_ping.py +++ b/tests/server/middleware/test_ping.py @@ -193,14 +193,7 @@ class TestPingMiddlewareIntegration: assert len(middleware._active_sessions) == 0 - # PingMiddleware keys its keepalive loop off the connection, which - # persists for the life of a handshake-era session. On the modern - # protocol version, a connection lives only for the single request - # that built it, so `_active_sessions` never holds a mid-session - # entry an outside observer can see — the register-and-clean-up - # happens entirely within one call. That per-request lifecycle is - # itself the reason this test pins the older era. - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: result = await client.call_tool("hello") assert result.content[0].text == "Hello!" @@ -221,10 +214,7 @@ class TestPingMiddlewareIntegration: def hello() -> str: return "Hello!" - # See the pin note in `test_ping_middleware_registers_session`: a - # mid-session `_active_sessions` entry is only observable when the - # connection persists across requests, which is handshake-era only. - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: await client.call_tool("hello") # Should have one active session assert len(middleware._active_sessions) == 1 diff --git a/tests/server/middleware/test_rate_limiting.py b/tests/server/middleware/test_rate_limiting.py index 525107ea4..4ae5ae114 100644 --- a/tests/server/middleware/test_rate_limiting.py +++ b/tests/server/middleware/test_rate_limiting.py @@ -60,14 +60,8 @@ class TestTokenBucketRateLimiter: # Should fail to consume more assert await limiter.consume(1) is False - async def test_refill(self, monkeypatch): + async def test_refill(self): """Test token refill over time.""" - current_time = 0.0 - monkeypatch.setattr( - "fastmcp.server.middleware.rate_limiting.time.time", - lambda: current_time, - ) - limiter = TokenBucketRateLimiter( capacity=10, refill_rate=10.0 ) # 10 tokens per second @@ -76,13 +70,11 @@ class TestTokenBucketRateLimiter: assert await limiter.consume(10) is True assert await limiter.consume(1) is False - # Advance the clock instead of sleeping in real time. Use a small - # base time and a slight margin over the strict 0.2s/2-token - # threshold so the result isn't sensitive to float rounding. - current_time += 0.25 + # Wait for refill (0.2 seconds = 2 tokens at 10/sec) + await asyncio.sleep(0.2) assert await limiter.consume(2) is True - async def test_denied_consumes_do_not_freeze_clock(self, monkeypatch): + async def test_denied_consumes_do_not_freeze_clock(self): """Regression for #4056: a client that retries quickly after being denied must not be able to bypass the configured refill rate. @@ -90,26 +82,21 @@ class TestTokenBucketRateLimiter: If it only advanced on success, the elapsed window would be re-counted on each retry, letting a client refill faster than `refill_rate`. """ - current_time = 0.0 - monkeypatch.setattr( - "fastmcp.server.middleware.rate_limiting.time.time", - lambda: current_time, - ) - limiter = TokenBucketRateLimiter(capacity=10, refill_rate=10.0) # Drain the bucket. assert await limiter.consume(10) is True - # Hammer with denied requests over a simulated ~0.2s (no real sleep). - # With the correct implementation, last_refill advances on each - # call, so total accumulated tokens after 0.2s is ~2 (10/s * 0.2s). + # Hammer with denied requests over ~0.2s. With the correct + # implementation, last_refill advances on each call, so total + # accumulated tokens after 0.2s is ~2 (10/s * 0.2s). for _ in range(20): await limiter.consume(1) - current_time += 0.01 + await asyncio.sleep(0.01) # We should NOT be able to consume more than the configured rate - # would allow over the elapsed window, well below `capacity`. + # would allow over the elapsed window. Allow a small slack for + # timing jitter, but stay well below `capacity`. assert await limiter.consume(5) is False, ( "denied retries should not silently accrue extra tokens" ) @@ -145,14 +132,8 @@ class TestSlidingWindowRateLimiter: # Should reject over limit assert await limiter.is_allowed() is False - async def test_sliding_window(self, monkeypatch): + async def test_sliding_window(self): """Test sliding window behavior.""" - current_time = 0.0 - monkeypatch.setattr( - "fastmcp.server.middleware.rate_limiting.time.time", - lambda: current_time, - ) - limiter = SlidingWindowRateLimiter(max_requests=2, window_seconds=1) # Use up requests @@ -160,8 +141,8 @@ class TestSlidingWindowRateLimiter: assert await limiter.is_allowed() is True assert await limiter.is_allowed() is False - # Advance the clock past the window instead of sleeping in real time - current_time += 1.1 + # Wait for window to pass + await asyncio.sleep(1.1) # Should be able to make requests again assert await limiter.is_allowed() is True @@ -547,11 +528,12 @@ class TestRateLimitingMiddlewareIntegration: async def test_rate_limiting_recovery_over_time(self, rate_limit_server): """Test that rate limiting allows requests again after time passes.""" - middleware = RateLimitingMiddleware( - max_requests_per_second=10.0, # 10 per second = 1 every 100ms - burst_capacity=4, + rate_limit_server.add_middleware( + RateLimitingMiddleware( + max_requests_per_second=10.0, # 10 per second = 1 every 100ms + burst_capacity=4, + ) ) - rate_limit_server.add_middleware(middleware) async with Client(rate_limit_server) as client: # Exhaust the burst; the exact number of internal requests before the @@ -565,11 +547,8 @@ class TestRateLimitingMiddlewareIntegration: break assert hit_limit, "Rate limit was never triggered" - # Simulate token refill without a real sleep: rewind each - # bucket's last-refill timestamp so the next consume() sees - # ~150ms of elapsed time (10 tokens/sec => ~1.5 tokens refilled). - for limiter in middleware.limiters.values(): - limiter.last_refill -= 0.15 + # Wait for token bucket to refill (150ms should be enough for ~1.5 tokens) + await asyncio.sleep(0.15) # Should be able to make another request result = await client.call_tool("quick_action", {"message": "after_wait"}) diff --git a/tests/server/middleware/test_tool_injection.py b/tests/server/middleware/test_tool_injection.py index dcbccf5a8..983736412 100644 --- a/tests/server/middleware/test_tool_injection.py +++ b/tests/server/middleware/test_tool_injection.py @@ -121,10 +121,7 @@ class TestToolInjectionMiddleware: ) base_server.add_middleware(middleware) - # Pinned to legacy: a middleware-injected tool's raised exception is - # surfaced with its message on the handshake era; the modern server - # runner reports it as a generic "Internal server error". - async with Client[FastMCPTransport](base_server, mode="legacy") as client: + async with Client[FastMCPTransport](base_server) as client: with pytest.raises(Exception, match="Cannot divide by zero"): _ = await client.call_tool(name="divide", arguments={"a": 10, "b": 0}) diff --git a/tests/server/mount/test_advanced.py b/tests/server/mount/test_advanced.py index 24c9765cd..5daeb65b8 100644 --- a/tests/server/mount/test_advanced.py +++ b/tests/server/mount/test_advanced.py @@ -1,7 +1,6 @@ """Advanced mounting scenarios.""" import pytest -from docket import Docket from mcp_types import TextContent from starlette.routing import Route @@ -9,18 +8,6 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.providers import FastMCPProvider from fastmcp.server.providers.wrapped_provider import _WrappedProvider -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import running_task_server - - -@pytest.fixture -def reset_docket_memory_server(): - """Force a fresh memory:// Docket server bound to this test's event loop.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") class TestDynamicChanges: @@ -611,19 +598,16 @@ class TestMountedServerDocketBehavior: includes Docket creation. """ - async def test_mounted_server_does_not_have_docket( - self, reset_docket_memory_server - ): + async def test_mounted_server_does_not_have_docket(self): """Test that a mounted server doesn't create its own Docket. MountedProvider.lifespan() should call only the server's _lifespan (user-defined lifespan), not _lifespan_manager (which includes Docket). """ main_app = FastMCP("MainApp") - main_app.add_extension(TasksExtension()) sub_app = FastMCP("SubApp") - # A task-enabled component on the parent makes it own a Docket. + # Need a task-enabled component to trigger Docket initialization @main_app.tool(task=True) async def _trigger_docket() -> str: return "trigger" @@ -634,15 +618,21 @@ class TestMountedServerDocketBehavior: main_app.mount(sub_app, "sub") - # After entering the parent's lifespan, only the parent owns a Docket. - async with running_task_server(main_app): - # The parent owns a Docket because it has a task-enabled component. + # After running the main app's lifespan, the sub app should not have + # its own Docket instance + async with Client(main_app) as client: + # The main app should have a docket (created by _lifespan_manager) + # because it has a task-enabled component assert main_app.docket is not None - # The mounted child does NOT own its own Docket; it uses the - # parent's Docket for background tasks. + # The mounted sub app should NOT have its own docket + # It uses the parent's docket for background tasks assert sub_app.docket is None + # But the tool should still work (prefixed as sub_my_tool) + result = await client.call_tool("sub_my_tool", {}) + assert result.data == "test" + class TestComponentServicePrefixLess: """Test that enable/disable works with prefix-less mounted servers.""" diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/providers/openapi/test_comprehensive.py index ace2a03a5..d5b8ceffd 100644 --- a/tests/server/providers/openapi/test_comprehensive.py +++ b/tests/server/providers/openapi/test_comprehensive.py @@ -653,6 +653,13 @@ class TestOpenAPIComprehensive: mock_response.json.return_value = {"code": 404, "message": "User not found"} mock_response.text = json.dumps({"code": 404, "message": "User not found"}) + # Configure raise_for_status to raise HTTPStatusError + def raise_for_status(): + raise httpx2.HTTPStatusError( + "404 Not Found", request=Mock(), response=mock_response + ) + + mock_response.raise_for_status = raise_for_status mock_client.send = AsyncMock(return_value=mock_response) server = create_openapi_server( diff --git a/tests/server/providers/openapi/test_legacy_client_compat.py b/tests/server/providers/openapi/test_legacy_client_compat.py index 3b0361ad8..707be823d 100644 --- a/tests/server/providers/openapi/test_legacy_client_compat.py +++ b/tests/server/providers/openapi/test_legacy_client_compat.py @@ -1,9 +1,20 @@ -"""Deprecation bridge for legacy-httpx OpenAPI clients.""" +"""Legacy-httpx client compatibility for the OpenAPI integration. + +The upgrade guide promises that an existing legacy ``httpx.AsyncClient`` passed +to ``OpenAPIProvider``/``FastMCP.from_openapi`` keeps working via duck-typing. +That requires two things of the OpenAPI request path: requests must be built +through the user's own client (``build_request``), and errors raised by that +client — which are legacy-httpx exceptions, not httpx2 — must still receive the +integration's specific error formatting rather than surfacing as generic +failures. +""" import pytest -from fastmcp import Client, FastMCP, FastMCPDeprecationWarning +from fastmcp import FastMCP +from fastmcp.client import Client from fastmcp.exceptions import ToolError +from fastmcp.server.providers.openapi import OpenAPIProvider httpx = pytest.importorskip("httpx", reason="legacy httpx not installed") @@ -15,6 +26,7 @@ SPEC = { "/items": { "get": { "operationId": "list_items", + "summary": "List items", "responses": { "200": { "description": "Items", @@ -34,77 +46,121 @@ SPEC = { } }, } - } + }, }, } -async def test_legacy_client_warns_and_remains_usable() -> None: +def _legacy_client(handler) -> "httpx.AsyncClient": + transport = httpx.MockTransport(handler) + return httpx.AsyncClient(transport=transport, base_url="https://api.example.com") + + +def _server(client) -> FastMCP: + mcp = FastMCP("Legacy Client Server") + mcp.add_provider(OpenAPIProvider(openapi_spec=SPEC, client=client)) + return mcp + + +async def test_tool_call_with_legacy_client_succeeds(): + """A legacy httpx.AsyncClient drives an OpenAPI tool end-to-end.""" + def handler(request: "httpx.Request") -> "httpx.Response": + assert isinstance(request, httpx.Request) return httpx.Response(200, json={"items": ["a", "b"]}) - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient( - transport=transport, - base_url="https://api.example.com", - ) as client: - with pytest.warns( - FastMCPDeprecationWarning, - match="httpx.AsyncClient.*deprecated", - ): - server = FastMCP.from_openapi(SPEC, client=client) - - async with Client(server) as mcp_client: + async with _legacy_client(handler) as client: + async with Client(_server(client)) as mcp_client: result = await mcp_client.call_tool("list_items", {}) - - assert result.structured_content == {"items": ["a", "b"]} + assert result.structured_content == {"items": ["a", "b"]} -async def test_legacy_client_preserves_http_error_details() -> None: +async def test_tool_http_error_keeps_openapi_formatting_with_legacy_client(): + """A legacy client's HTTP error still gets the integration's message format. + + The handler raises legacy ``httpx.HTTPStatusError``; the catch tuples must + recognize it so the error carries the formatted status + body rather than a + generic failure. + """ + def handler(request: "httpx.Request") -> "httpx.Response": - return httpx.Response(404, json={"detail": "items not found"}) + return httpx.Response(500, json={"detail": "boom"}) - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient( - transport=transport, - base_url="https://api.example.com", - ) as client: - with pytest.warns(FastMCPDeprecationWarning): - server = FastMCP.from_openapi(SPEC, client=client) + async with _legacy_client(handler) as client: + async with Client(_server(client)) as mcp_client: + with pytest.raises(ToolError, match="HTTP error 500") as excinfo: + await mcp_client.call_tool("list_items", {}) + assert "boom" in str(excinfo.value) - async with Client(server) as mcp_client: - with pytest.raises(ToolError, match="HTTP error 404") as exc_info: + +async def test_tool_request_error_keeps_openapi_formatting_with_legacy_client(): + """A legacy client's transport error maps to the formatted request error.""" + + def handler(request: "httpx.Request") -> "httpx.Response": + raise httpx.ConnectError("connection refused") + + async with _legacy_client(handler) as client: + async with Client(_server(client)) as mcp_client: + with pytest.raises(ToolError, match="Request error"): await mcp_client.call_tool("list_items", {}) - assert "items not found" in str(exc_info.value) +async def test_multipart_tool_call_with_legacy_client(): + """Multipart bodies must materialize and send through a legacy client too.""" + spec = { + "openapi": "3.0.0", + "info": {"title": "Upload API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/upload": { + "post": { + "operationId": "upload_file", + "summary": "Upload a file", + "requestBody": { + "required": True, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": {"file": {"type": "string"}}, + } + } + }, + }, + "responses": { + "200": { + "description": "Uploaded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"ok": {"type": "boolean"}}, + } + } + }, + } + }, + } + } + }, + } + received: dict[str, object] = {} -@pytest.mark.parametrize( - ("error_kind", "message"), - [ - ("timeout", "HTTP request timed out (ReadTimeout)"), - ("connect", "Request error (ConnectError)"), - ], -) -async def test_legacy_client_preserves_transport_error_details( - error_kind: str, - message: str, -) -> None: def handler(request: "httpx.Request") -> "httpx.Response": - if error_kind == "timeout": - raise httpx.ReadTimeout("transport failed", request=request) - raise httpx.ConnectError("transport failed", request=request) + received["content_type"] = request.headers.get("content-type", "") + received["body"] = request.read() + return httpx.Response(200, json={"ok": True}) - transport = httpx.MockTransport(handler) - async with httpx.AsyncClient( - transport=transport, - base_url="https://api.example.com", - ) as client: - with pytest.warns(FastMCPDeprecationWarning): - server = FastMCP.from_openapi(SPEC, client=client) + async with _legacy_client(handler) as client: + mcp = FastMCP("Legacy Multipart Server") + mcp.add_provider(OpenAPIProvider(openapi_spec=spec, client=client)) + async with Client(mcp) as mcp_client: + result = await mcp_client.call_tool("upload_file", {"file": "data"}) + assert result.structured_content == {"ok": True} - async with Client(server) as mcp_client: - with pytest.raises(ToolError) as exc_info: - await mcp_client.call_tool("list_items", {}) - - assert message in str(exc_info.value) + content_type = received["content_type"] + assert isinstance(content_type, str) + assert "multipart/form-data" in content_type + body = received["body"] + assert isinstance(body, bytes) + assert b"data" in body diff --git a/tests/server/providers/openapi/test_openapi_discriminator.py b/tests/server/providers/openapi/test_openapi_discriminator.py deleted file mode 100644 index 9eca4ece0..000000000 --- a/tests/server/providers/openapi/test_openapi_discriminator.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Tests for OpenAPI discriminator handling in OpenAPIProvider.""" - -import json -from typing import Any - -import httpx2 -import pytest - -from fastmcp import FastMCP -from fastmcp.client import Client -from fastmcp.server.providers.openapi import OpenAPIProvider - - -def create_openapi_server(openapi_spec: dict, client) -> FastMCP: - """Helper to create a FastMCP server with OpenAPIProvider.""" - mcp = FastMCP("OpenAPI Server") - mcp.add_provider(OpenAPIProvider(openapi_spec=openapi_spec, client=client)) - return mcp - - -def discriminator_spec( - mapping: dict[str, str] | None = None, - body_ref: str = "Pet", -) -> dict[str, Any]: - """A parent schema with a discriminator mapping onto two allOf subtypes.""" - if mapping is None: - mapping = { - "cat": "#/components/schemas/Cat", - "dog": "#/components/schemas/Dog", - } - return { - "openapi": "3.1.0", - "info": {"title": "Pet API", "version": "1.0.0"}, - "servers": [{"url": "https://api.example.com"}], - "paths": { - "/pets": { - "post": { - "operationId": "create_pet", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": {"$ref": f"#/components/schemas/{body_ref}"} - } - }, - }, - "responses": {"200": {"description": "Created"}}, - } - } - }, - "components": { - "schemas": { - "Pet": { - "type": "object", - "properties": {"petType": {"type": "string"}}, - "required": ["petType"], - "discriminator": { - "propertyName": "petType", - "mapping": mapping, - }, - }, - "Cat": { - "allOf": [ - {"$ref": "#/components/schemas/Pet"}, - { - "type": "object", - "properties": {"meowVolume": {"type": "integer"}}, - "required": ["meowVolume"], - }, - ] - }, - "Dog": { - "allOf": [ - {"$ref": "#/components/schemas/Pet"}, - { - "type": "object", - "properties": {"packSize": {"type": "integer"}}, - "required": ["packSize"], - }, - ] - }, - } - }, - } - - -def colliding_variant_spec() -> dict[str, Any]: - """Subtypes that disagree about the shape of the discriminator property. - - The parent marks ``kind`` required without declaring it, so each subtype's - own ``const`` is the only schema available for that field. - """ - return { - "openapi": "3.1.0", - "info": {"title": "Pet API", "version": "1.0.0"}, - "servers": [{"url": "https://api.example.com"}], - "paths": { - "/pets": { - "post": { - "operationId": "create_pet", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/Pet"} - } - }, - }, - "responses": {"200": {"description": "Created"}}, - } - } - }, - "components": { - "schemas": { - "Pet": { - "type": "object", - "required": ["kind"], - "discriminator": { - "propertyName": "kind", - "mapping": {"cat": "Cat", "dog": "Dog"}, - }, - }, - "Cat": { - "allOf": [ - {"$ref": "#/components/schemas/Pet"}, - { - "type": "object", - "properties": { - "kind": {"const": "cat"}, - "meowVolume": {"type": "integer"}, - }, - }, - ] - }, - "Dog": { - "allOf": [ - {"$ref": "#/components/schemas/Pet"}, - { - "type": "object", - "properties": { - "kind": {"const": "dog"}, - "packSize": {"type": "integer"}, - }, - }, - ] - }, - } - }, - } - - -def propertyless_variant_spec() -> dict[str, Any]: - """Subtypes that add nothing beyond the parent they compose.""" - spec = discriminator_spec() - for name in ("Cat", "Dog"): - spec["components"]["schemas"][name] = { - "allOf": [{"$ref": "#/components/schemas/Pet"}] - } - return spec - - -async def tool_schema(spec: dict[str, Any]) -> dict[str, Any]: - """Build the server and return the generated input schema for create_pet.""" - async with httpx2.AsyncClient( - transport=httpx2.MockTransport( - lambda request: httpx2.Response(200, json={"ok": True}) - ), - base_url="https://api.example.com", - ) as client: - server = create_openapi_server(spec, client) - async with Client(server) as mcp_client: - tools = await mcp_client.list_tools() - return next(t for t in tools if t.name == "create_pet").input_schema - - -class TestDiscriminatorRequestBodies: - """Subtypes named by a discriminator mapping are flattened in as optional.""" - - async def test_subtype_fields_are_advertised(self): - """Fields reachable only through discriminator.mapping reach the schema.""" - schema = await tool_schema(discriminator_spec()) - - assert schema["properties"].keys() >= {"petType", "meowVolume", "packSize"} - - async def test_subtype_fields_are_optional(self): - """Only the discriminator is required; variant fields never are.""" - schema = await tool_schema(discriminator_spec()) - - assert schema["required"] == ["petType"] - - async def test_discriminator_property_describes_the_variants(self): - """The discriminator names which fields belong to which variant.""" - schema = await tool_schema(discriminator_spec()) - - description = schema["properties"]["petType"]["description"] - assert "meowVolume" in description - assert "packSize" in description - - async def test_discriminator_keyword_is_dropped(self): - """The mapping points at $defs that get pruned, so it cannot survive.""" - schema = await tool_schema(discriminator_spec()) - - assert "discriminator" not in schema - assert "discriminator" not in schema["properties"]["petType"] - - @pytest.mark.parametrize( - "mapping", - [ - pytest.param({"cat": "#/components/schemas/Missing"}, id="missing_ref"), - pytest.param({"cat": "Missing"}, id="missing_name"), - pytest.param({"cat": "https://example.com/Cat"}, id="remote_target"), - pytest.param({"cat": "#/definitions/Cat"}, id="unsupported_pointer"), - ], - ) - async def test_unresolvable_mapping_is_ignored(self, mapping: dict[str, str]): - """An unusable mapping leaves the parent schema as it was.""" - schema = await tool_schema(discriminator_spec(mapping=mapping)) - - assert set(schema["properties"]) == {"petType"} - - async def test_bare_schema_name_mapping_resolves(self): - """Mapping values may be schema names, not just references.""" - schema = await tool_schema( - discriminator_spec(mapping={"cat": "Cat", "dog": "Dog"}) - ) - - assert schema["properties"].keys() >= {"petType", "meowVolume", "packSize"} - - async def test_selected_variant_field_reaches_the_request_body(self): - """The reported failure: meowVolume must reach the upstream API.""" - received: dict[str, object] = {} - - def handler(request): - received["body"] = json.loads(request.content) - return httpx2.Response(200, json={"ok": True}) - - async with httpx2.AsyncClient( - transport=httpx2.MockTransport(handler), - base_url="https://api.example.com", - ) as client: - server = create_openapi_server(discriminator_spec(), client) - async with Client(server) as mcp_client: - result = await mcp_client.call_tool( - "create_pet", {"petType": "cat", "meowVolume": 11} - ) - - assert result.structured_content == {"ok": True} - assert received["body"] == {"petType": "cat", "meowVolume": 11} - - async def test_accepted_values_are_advertised(self): - """The legal tags are named even when no variant adds a field.""" - schema = await tool_schema(propertyless_variant_spec()) - - description = schema["properties"]["petType"]["description"] - assert "'cat'" in description - assert "'dog'" in description - - async def test_propertyless_variant_is_still_named(self): - """A variant adding no fields remains a legal discriminator value.""" - spec = discriminator_spec() - spec["components"]["schemas"]["Dog"] = { - "allOf": [{"$ref": "#/components/schemas/Pet"}] - } - - schema = await tool_schema(spec) - - description = schema["properties"]["petType"]["description"] - assert "'dog'" in description - assert "meowVolume" in description - - async def test_conflicting_variant_schemas_are_unioned(self): - """No variant's constraint may be advertised as if it applied to all.""" - schema = await tool_schema(colliding_variant_spec()) - - kind = schema["properties"]["kind"] - assert [alternative.get("const") for alternative in kind["anyOf"]] == [ - "cat", - "dog", - ] - - async def test_subtype_body_is_unaffected(self): - """A body referencing the child still resolves through allOf only.""" - schema = await tool_schema(discriminator_spec(body_ref="Cat")) - - assert set(schema["properties"]) == {"petType", "meowVolume"} - assert sorted(schema["required"]) == ["meowVolume", "petType"] diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py index 35ddc4aa0..ceabe117d 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/providers/openapi/test_openapi_features.py @@ -1,6 +1,5 @@ """Tests for OpenAPI feature support in OpenAPIProvider.""" -import json from typing import Any from unittest.mock import AsyncMock, Mock @@ -1413,117 +1412,3 @@ class TestMultipartUpload: assert "multipart/form-data" in received["content_type"] assert b"data" in received["body"] - - -class TestAllOfReferenceRequestBodies: - """Request bodies keep fields inherited through an allOf reference.""" - - SPEC = { - "openapi": "3.1.0", - "info": {"title": "Pet API", "version": "1.0.0"}, - "servers": [{"url": "https://api.example.com"}], - "paths": { - "/pets": { - "post": { - "operationId": "create_pet", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/Cat"} - } - }, - }, - "responses": {"200": {"description": "Created"}}, - } - } - }, - "components": { - "schemas": { - "Animal": { - "type": "object", - "properties": {"animalId": {"type": "string"}}, - "required": ["animalId"], - }, - "Pet": { - "allOf": [ - {"$ref": "#/components/schemas/Animal"}, - { - "type": "object", - "properties": {"petType": {"type": "string"}}, - "required": ["petType"], - }, - ] - }, - "Cat": { - "allOf": [ - {"$ref": "#/components/schemas/Pet"}, - { - "type": "object", - "properties": {"meowVolume": {"type": "integer"}}, - "required": ["meowVolume"], - }, - ] - }, - } - }, - } - - async def test_allof_reference_fields_reach_tool_schema_and_request_body(self): - received: dict[str, object] = {} - - def handler(request): - received["body"] = json.loads(request.content) - return httpx2.Response(200, json={"ok": True}) - - async with httpx2.AsyncClient( - transport=httpx2.MockTransport(handler), - base_url="https://api.example.com", - ) as client: - server = create_openapi_server(self.SPEC, client) - async with Client(server) as mcp_client: - tools = await mcp_client.list_tools() - tool = next(tool for tool in tools if tool.name == "create_pet") - assert tool.input_schema["properties"].keys() >= { - "animalId", - "petType", - "meowVolume", - } - - result = await mcp_client.call_tool( - "create_pet", - {"animalId": "a-1", "petType": "cat", "meowVolume": 11}, - ) - - assert result.structured_content == {"ok": True} - assert received["body"] == { - "animalId": "a-1", - "petType": "cat", - "meowVolume": 11, - } - - async def test_allof_reference_request_body_does_not_crash(self): - """Required fields inherited through a reference can be sent together.""" - received: dict[str, object] = {} - - def handler(request): - received["body"] = json.loads(request.content) - return httpx2.Response(200, json={"ok": True}) - - async with httpx2.AsyncClient( - transport=httpx2.MockTransport(handler), - base_url="https://api.example.com", - ) as client: - server = create_openapi_server(self.SPEC, client) - async with Client(server) as mcp_client: - result = await mcp_client.call_tool( - "create_pet", - {"animalId": "a-1", "petType": "cat", "meowVolume": 11}, - ) - - assert result.structured_content == {"ok": True} - assert received["body"] == { - "animalId": "a-1", - "petType": "cat", - "meowVolume": 11, - } diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py index 53b37129a..216d283e8 100644 --- a/tests/server/providers/proxy/test_proxy_client.py +++ b/tests/server/providers/proxy/test_proxy_client.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import cast import pytest from anyio import create_task_group @@ -8,7 +7,6 @@ from mcp_types import ( LoggingLevel, ModelHint, ModelPreferences, - Root, TextContent, ) from pydantic import BaseModel, Field @@ -23,70 +21,6 @@ from fastmcp.server.elicitation import AcceptedElicitation from fastmcp.server.providers.proxy import ProxyClient, _create_client_factory -class TestProxyClientEraDefault: - """`ProxyClient` pins the handshake era independently of `Client`'s default. - - `fastmcp.Client` defaults to `mode="auto"` (negotiate the newest mutual era), - but a proxy backend forwards the initialize handshake and server-initiated - push features (sampling / elicitation / roots / logging), which live only on - the handshake era. So `ProxyClient` must default to `"legacy"` regardless of - what `Client` defaults to — flipping the general client default must never - change proxy behavior. - """ - - def test_client_default_is_auto(self): - mcp = FastMCP("Backend") - assert Client(mcp).mode == "auto" - - def test_proxy_client_defaults_to_legacy(self): - mcp = FastMCP("Backend") - assert ProxyClient(mcp).mode == "legacy" - - def test_proxy_client_can_opt_into_auto(self): - """The legacy default is an override-able floor, not a hard pin.""" - mcp = FastMCP("Backend") - assert ProxyClient(mcp, mode="auto").mode == "auto" - - def test_create_proxy_backend_defaults_to_legacy(self): - """The backend client `create_proxy` builds is legacy by default too.""" - mcp = FastMCP("Backend") - factory = _create_client_factory(mcp) - assert cast(Client, factory()).mode == "legacy" - - def test_create_proxy_backend_honors_explicit_mode(self): - mcp = FastMCP("Backend") - factory = _create_client_factory(mcp, mode="auto") - assert cast(Client, factory()).mode == "auto" - - -async def _backend_list_roots(context: Context) -> list[Root]: - """Issue a handshake-era `roots/list` from a backend server. - - `Context` has no `list_roots()`: server-initiated requests are not part of - FastMCP's server API. These helpers reach the SDK session directly to stand - in for a legacy upstream, which is the only thing the proxy relay forwards. - """ - result = await context.session.list_roots() # ty: ignore[deprecated] - return result.roots - - -async def _backend_sample(context: Context) -> str: - """Issue a handshake-era `sampling/createMessage` from a backend server.""" - result = await context.session.create_message( # ty: ignore[deprecated] - messages=[ - SamplingMessage( - role="user", content=TextContent(type="text", text="Hello, world!") - ) - ], - system_prompt="You love FastMCP", - temperature=0.5, - max_tokens=100, - model_preferences=ModelPreferences(hints=[ModelHint(name="gpt-4o")]), - related_request_id=context.origin_request_id, - ) - return result.content.text if isinstance(result.content, TextContent) else "" - - @pytest.fixture def fastmcp_server(): mcp = FastMCP("TestServer") @@ -97,13 +31,21 @@ def fastmcp_server(): @mcp.tool async def list_roots(context: Context) -> list[str]: - return [str(r.uri) for r in await _backend_list_roots(context)] + roots = await context.list_roots() + return [str(r.uri) for r in roots] @mcp.tool async def sampling( context: Context, ) -> str: - return await _backend_sample(context) + result = await context.sample( + "Hello, world!", + system_prompt="You love FastMCP", + temperature=0.5, + max_tokens=100, + model_preferences="gpt-4o", + ) + return result.text or "" @dataclass class Person: @@ -146,15 +88,6 @@ def fastmcp_server(): async def proxy_server(fastmcp_server: FastMCP): """ A proxy server that forwards interactions with the proxy client to the given fastmcp server. - - `ProxyClient(fastmcp_server)` defaults to `mode="legacy"` (see - `TestProxyClientEraDefault` above — a directly-constructed `ProxyClient` - always pins the handshake era, independent of `create_proxy`'s era - mirroring). Tests below that exercise a handshake-only feature (roots / - sampling / elicitation push, logging, progress) pin their front `Client` - to `mode="legacy"` too: the modern era has no back-channel for - server-initiated requests at all, so these forwarding paths cannot exist - there. """ return create_proxy(ProxyClient(fastmcp_server)) @@ -173,7 +106,7 @@ class TestProxyClient: """ Test that the proxy client correctly forwards an error response. """ - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: with pytest.raises(ToolError, match="Elicitation not supported"): await client.call_tool("elicit", {}) @@ -188,7 +121,7 @@ class TestProxyClient: roots_handler_called = True return [] - async with Client(proxy_server, mode="legacy", roots=roots_handler) as client: + async with Client(proxy_server, roots=roots_handler) as client: await client.call_tool("list_roots", {}) assert roots_handler_called @@ -197,9 +130,7 @@ class TestProxyClient: """ Test that the proxy client correctly forwards the `list_roots` response. """ - async with Client( - proxy_server, mode="legacy", roots=["file://x/y/z"] - ) as client: + async with Client(proxy_server, roots=["file://x/y/z"]) as client: result = await client.call_tool("list_roots", {}) assert result.data == ["file://x/y/z"] @@ -230,9 +161,7 @@ class TestProxyClient: ) return "" - async with Client( - proxy_server, mode="legacy", sampling_handler=sampling_handler - ) as client: + async with Client(proxy_server, sampling_handler=sampling_handler) as client: await client.call_tool("sampling", {}) assert sampling_handler_called @@ -242,7 +171,7 @@ class TestProxyClient: Test that the proxy client correctly forwards the `sampling` response. """ async with Client( - proxy_server, mode="legacy", sampling_handler=lambda *args: "I love FastMCP" + proxy_server, sampling_handler=lambda *args: "I love FastMCP" ) as client: result = await client.call_tool("sampling", {}) assert result.data == "I love FastMCP" @@ -270,7 +199,7 @@ class TestProxyClient: return ElicitResult(action="accept", content=response_type(name="Alice")) async with Client( - proxy_server, mode="legacy", elicitation_handler=elicitation_handler + proxy_server, elicitation_handler=elicitation_handler ) as client: await client.call_tool("elicit", {}) @@ -288,7 +217,6 @@ class TestProxyClient: async with Client( proxy_server, - mode="legacy", elicitation_handler=elicitation_handler, ) as client: result = await client.call_tool("elicit", {}) @@ -305,7 +233,7 @@ class TestProxyClient: return ElicitResult(action="decline") async with Client( - proxy_server, mode="legacy", elicitation_handler=elicitation_handler + proxy_server, elicitation_handler=elicitation_handler ) as client: result = await client.call_tool("elicit", {}) assert result.data == "No name provided." @@ -323,9 +251,7 @@ class TestProxyClient: assert message.level == "info" assert message.logger == "test" - async with Client( - proxy_server, mode="legacy", log_handler=log_handler - ) as client: + async with Client(proxy_server, log_handler=log_handler) as client: await client.call_tool( "log", {"message": "Hello, world!", "level": "info", "logger": "test"} ) @@ -351,9 +277,7 @@ class TestProxyClient: dict(progress=progress, total=total, message=message) ) - async with Client( - proxy_server, mode="legacy", progress_handler=progress_handler - ) as client: + async with Client(proxy_server, progress_handler=progress_handler) as client: await client.call_tool("report_progress", {}) assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES @@ -369,8 +293,8 @@ class TestProxyClient: results["logger_b"] = message async with ( - Client(proxy_server, mode="legacy", log_handler=log_handler_a) as client_a, - Client(proxy_server, mode="legacy", log_handler=log_handler_b) as client_b, + Client(proxy_server, log_handler=log_handler_a) as client_a, + Client(proxy_server, log_handler=log_handler_b) as client_b, ): async with create_task_group() as tg: tg.start_soon( @@ -412,12 +336,8 @@ class TestProxyClient: results[name] = result.data async with ( - Client( - proxy_server, mode="legacy", elicitation_handler=elicitation_handler_a - ) as client_a, - Client( - proxy_server, mode="legacy", elicitation_handler=elicitation_handler_b - ) as client_b, + Client(proxy_server, elicitation_handler=elicitation_handler_a) as client_a, + Client(proxy_server, elicitation_handler=elicitation_handler_b) as client_b, ): async with create_task_group() as tg: tg.start_soon( @@ -476,7 +396,7 @@ class TestProxyClient: return {"content": "Test content", "acknowledge": True} async with Client( - proxy_server, mode="legacy", elicitation_handler=elicitation_handler + proxy_server, elicitation_handler=elicitation_handler ) as client: result = await client.call_tool("elicit_with_defaults", {}) assert result.data == "Content: Test content, Acknowledge: True" @@ -527,16 +447,17 @@ def roots_backend_server(): @mcp.resource("data://roots") async def roots_resource(context: Context) -> list[str]: - return [str(r.uri) for r in await _backend_list_roots(context)] + roots = await context.list_roots() + return [str(r.uri) for r in roots] @mcp.resource("data://roots/{key}") async def roots_template(key: str, context: Context) -> str: - roots = await _backend_list_roots(context) + roots = await context.list_roots() return ", ".join(f"{key}:{r.uri}" for r in roots) @mcp.prompt async def roots_prompt(context: Context) -> str: - roots = await _backend_list_roots(context) + roots = await context.list_roots() return ", ".join(str(r.uri) for r in roots) return mcp @@ -555,12 +476,6 @@ class TestProxyServerInitiatedForwardingNonTool: Before the fix, only ProxyTool.run stashed the proxy's request context, so resources/templates/prompts forwarded the request into the backend's own context and deadlocked. - - Every test here pins the front to `mode="legacy"`: `roots/list` is a - server-initiated request over the handshake's back-channel, which the - modern (2026-07-28) era removes entirely — a modern front raises "this - transport context has no back-channel for server-initiated requests" - rather than reaching the roots handler at all. """ async def test_proxied_resource_forwards_list_roots( @@ -573,9 +488,7 @@ class TestProxyServerInitiatedForwardingNonTool: roots_handler_called = True return ["file://from/client"] - async with Client( - roots_proxy_server, mode="legacy", roots=roots_handler - ) as client: + async with Client(roots_proxy_server, roots=roots_handler) as client: result = await client.read_resource("data://roots") assert roots_handler_called @@ -591,9 +504,7 @@ class TestProxyServerInitiatedForwardingNonTool: roots_handler_called = True return ["file://from/client"] - async with Client( - roots_proxy_server, mode="legacy", roots=roots_handler - ) as client: + async with Client(roots_proxy_server, roots=roots_handler) as client: result = await client.read_resource("data://roots/abc") assert roots_handler_called @@ -609,9 +520,7 @@ class TestProxyServerInitiatedForwardingNonTool: roots_handler_called = True return ["file://from/client"] - async with Client( - roots_proxy_server, mode="legacy", roots=roots_handler - ) as client: + async with Client(roots_proxy_server, roots=roots_handler) as client: result = await client.get_prompt("roots_prompt") assert roots_handler_called diff --git a/tests/server/providers/proxy/test_proxy_request_meta.py b/tests/server/providers/proxy/test_proxy_request_meta.py deleted file mode 100644 index 4776eedb5..000000000 --- a/tests/server/providers/proxy/test_proxy_request_meta.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Request `_meta` ownership at the proxy's backend connection boundary. - -Protocol version, client identity, and client capabilities describe one -negotiated MCP connection. The proxy must never copy them from its frontend -connection onto its backend connection: a modern backend session stamps its -own values, and a handshake-era backend must not receive them at all. -Progress, tracing, task, and application metadata pass through untouched. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -import mcp_types -import pytest -from mcp.client.extension import ClientExtension -from mcp_types.version import MODERN_PROTOCOL_VERSIONS - -from fastmcp import Client, Context, FastMCP -from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient - -FRONT_EXTENSION_ID = "example.com/frontend" -FRONT_INFO = mcp_types.Implementation(name="frontend-client", version="1.0") -BACKEND_INFO = mcp_types.Implementation(name="proxy-backend", version="1.0") -RESERVED_META_KEYS = { - mcp_types.PROTOCOL_VERSION_META_KEY, - mcp_types.CLIENT_INFO_META_KEY, - mcp_types.CLIENT_CAPABILITIES_META_KEY, -} - - -@dataclass -class _RecordedRequest: - protocol_version: str - meta: dict[str, Any] - - -class _FrontendExtension(ClientExtension): - identifier = FRONT_EXTENSION_ID - - def settings(self) -> dict[str, Any]: - return {"frontend": True} - - -def _recording_backend(seen: dict[str, _RecordedRequest]) -> FastMCP: - backend = FastMCP("metadata-backend") - - def record(operation: str, ctx: Context) -> None: - request_context = ctx.request_context - assert request_context is not None - seen[operation] = _RecordedRequest( - protocol_version=request_context.protocol_version, - meta=dict(request_context.meta or {}), - ) - - @backend.tool - def inspect_tool(ctx: Context) -> str: - record("tool", ctx) - return "ok" - - @backend.resource("data://metadata") - def inspect_resource(ctx: Context) -> str: - record("resource", ctx) - return "ok" - - @backend.resource("data://items/{item_id}") - def inspect_template(item_id: str, ctx: Context) -> str: - record("template", ctx) - return "ok" - - @backend.prompt - def inspect_prompt(ctx: Context) -> str: - record("prompt", ctx) - return "ok" - - return backend - - -def _proxy( - backend: FastMCP, *, backend_mode: str, client_class: type[Client] -) -> FastMCPProxy: - return FastMCPProxy( - client_factory=lambda: client_class( - backend, - mode=backend_mode, - client_info=BACKEND_INFO, - ) - ) - - -def _assert_backend_connection_meta(record: _RecordedRequest, modern: bool) -> None: - """The backend request carries the backend connection's own envelope. - - On a handshake-era backend the reserved keys are absent. On a modern - backend they hold the backend session's negotiated version and the proxy - client's identity and capabilities — never the frontend client's. - """ - meta = record.meta - if not modern: - assert RESERVED_META_KEYS.isdisjoint(meta) - return - - assert meta[mcp_types.PROTOCOL_VERSION_META_KEY] == record.protocol_version - assert meta[mcp_types.CLIENT_INFO_META_KEY] == BACKEND_INFO.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - capabilities = meta[mcp_types.CLIENT_CAPABILITIES_META_KEY] - assert FRONT_EXTENSION_ID not in capabilities.get("extensions", {}) - - -# Every allowed ClientFactoryT shape must be hop-safe, not just ProxyClient: -# a plain Client backend runs the SDK's stock ClientSession rather than the -# proxy's session class, so it exercises the copy-site sanitization alone. -@pytest.mark.parametrize("client_class", [ProxyClient, Client]) -@pytest.mark.parametrize( - ("front_mode", "backend_mode", "backend_is_modern"), - [ - ("auto", "auto", True), - ("auto", "legacy", False), - ("legacy", "auto", True), - ("legacy", "legacy", False), - ], -) -async def test_forwarded_tool_meta_stays_hop_safe( - front_mode: str, - backend_mode: str, - backend_is_modern: bool, - client_class: type[Client], -): - seen: dict[str, _RecordedRequest] = {} - proxy = _proxy( - _recording_backend(seen), backend_mode=backend_mode, client_class=client_class - ) - - async with Client( - proxy, - mode=front_mode, - client_info=FRONT_INFO, - extensions=[_FrontendExtension()], - ) as client: - await client.call_tool( - "inspect_tool", - meta={ - "progressToken": "front-progress", - "example.com/vendor": {"request": "kept"}, - }, - ) - - record = seen["tool"] - assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern - assert isinstance(record.meta["progressToken"], str | int) - assert record.meta["example.com/vendor"] == {"request": "kept"} - _assert_backend_connection_meta(record, backend_is_modern) - - -@pytest.mark.parametrize("client_class", [ProxyClient, Client]) -@pytest.mark.parametrize( - ("backend_mode", "backend_is_modern"), - [("auto", True), ("legacy", False)], -) -@pytest.mark.parametrize("operation", ["resource", "template", "prompt"]) -async def test_non_tool_requests_forward_hop_safe_metadata( - operation: str, - backend_mode: str, - backend_is_modern: bool, - client_class: type[Client], -): - seen: dict[str, _RecordedRequest] = {} - proxy = _proxy( - _recording_backend(seen), backend_mode=backend_mode, client_class=client_class - ) - meta = {"example.com/vendor": {"operation": operation}} - - async with Client( - proxy, - mode="auto", - client_info=FRONT_INFO, - extensions=[_FrontendExtension()], - ) as client: - if operation == "resource": - await client.read_resource("data://metadata", meta=meta) - elif operation == "template": - await client.read_resource("data://items/42", meta=meta) - else: - await client.get_prompt("inspect_prompt", meta=meta) - - record = seen[operation] - assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern - assert record.meta["example.com/vendor"] == {"operation": operation} - _assert_backend_connection_meta(record, backend_is_modern) diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py index 997c65700..9f1579b45 100644 --- a/tests/server/providers/proxy/test_proxy_server.py +++ b/tests/server/providers/proxy/test_proxy_server.py @@ -4,30 +4,24 @@ import time from typing import Any, cast from unittest.mock import AsyncMock, patch -import httpx2 import mcp_types import pytest from anyio import create_task_group from dirty_equals import Contains from mcp import MCPError from mcp_types import Icon, TextContent, TextResourceContents -from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import AnyUrl from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport, StreamableHttpTransport -from fastmcp.client.transports.base import TransportOptions from fastmcp.exceptions import ToolError -from fastmcp.mcp_config import MCPConfig from fastmcp.resources import ResourceContent, ResourceResult from fastmcp.server import create_proxy -from fastmcp.server.middleware import Middleware from fastmcp.server.providers.proxy import ( FastMCPProxy, ProxyClient, ProxyProvider, - _ForwardingClientSession, ) from fastmcp.tools.base import ToolResult from fastmcp.tools.tool_transform import ( @@ -35,7 +29,6 @@ from fastmcp.tools.tool_transform import ( ) from fastmcp.utilities.http import find_available_port from fastmcp.utilities.tests import run_server_async -from tests.conftest import user_meta USERS = [ {"id": "1", "name": "Alice", "active": True}, @@ -166,14 +159,7 @@ def fastmcp_server(): @pytest.fixture async def proxy_server(fastmcp_server): - """Fixture that creates a FastMCP proxy server. - - Passing an already-constructed `ProxyClient` as the target (rather than a - raw `FastMCP`/URL/etc.) means `create_proxy` reuses that client as-is - instead of building one through the era-mirroring factory — so this - backend stays pinned to `ProxyClient`'s own default of `mode="legacy"` - regardless of what era the front client negotiates. - """ + """Fixture that creates a FastMCP proxy server.""" return create_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server))) @@ -204,12 +190,13 @@ async def test_create_proxy_with_transport(fastmcp_server): async def test_proxy_forwards_upstream_instructions(): - """The metadata middleware forwards upstream instructions.""" + """A proxy should surface the upstream server's instructions in the handshake.""" upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123") proxy = create_proxy(upstream, name="proxy") async with Client(proxy) as client: - assert client.session.instructions == "USE_THIS_MARKER_123" + assert client.initialize_result is not None + assert client.initialize_result.instructions == "USE_THIS_MARKER_123" async def test_proxy_own_instructions_take_precedence(): @@ -218,7 +205,8 @@ async def test_proxy_own_instructions_take_precedence(): proxy = create_proxy(upstream, name="proxy", instructions="proxy instructions") async with Client(proxy) as client: - assert client.session.instructions == "proxy instructions" + assert client.initialize_result is not None + assert client.initialize_result.instructions == "proxy instructions" async def test_proxy_instructions_none_when_upstream_has_none(): @@ -227,7 +215,8 @@ async def test_proxy_instructions_none_when_upstream_has_none(): proxy = create_proxy(upstream, name="proxy") async with Client(proxy) as client: - assert client.session.instructions is None + assert client.initialize_result is not None + assert client.initialize_result.instructions is None def test_create_proxy_with_url(): @@ -259,7 +248,7 @@ async def test_proxy_with_async_client_factory(): async def test_proxy_ping_forwards_to_remote_server(fastmcp_server): proxy = create_proxy(fastmcp_server) - async with Client(proxy, mode="legacy") as client: + async with Client(proxy) as client: assert await client.ping() is True @@ -268,35 +257,14 @@ async def test_proxy_ping_surfaces_wrong_remote_path(): async with run_server_async(remote, transport="http") as url: proxy = create_proxy(StreamableHttpTransport(url.removesuffix("/mcp"))) - # Optional metadata lookup is best-effort, so the client can connect. The - # first real proxied operation reports the bad backend path instead. - async with Client(proxy, mode="legacy") as client: - with pytest.raises(MCPError, match="Not Found"): - await client.ping() + # SDK v2 surfaces a wrong remote path as an HTTP "Not Found" rather than + # the v1 "Session terminated" message. + with pytest.raises(MCPError, match="Not Found"): + async with Client(proxy): + pass -async def test_proxy_initialize_defers_remote_connection_error(): - port = find_available_port() - proxy = create_proxy( - StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), - provider_error_strategy="raise", - ) - - # The client can connect without optional backend metadata; the first - # component operation reports the unavailable backend. - async with Client(proxy, mode="legacy") as client: - with pytest.raises(MCPError, match="Client failed to connect"): - await client.list_tools() - - -async def test_proxy_list_tools_surfaces_remote_connection_error(): - """A dead backend surfaces as an MCPError naming the connection failure. - - The provider normalizes transport failures into `MCPError` (rather than - letting the client's `RuntimeError` escape) so the error survives the - modern era's wire boundary, which masks any non-MCPError as a generic - "Internal server error". - """ +async def test_proxy_initialize_forwards_remote_connection_error(): port = find_available_port() proxy = create_proxy( StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), @@ -304,15 +272,22 @@ async def test_proxy_list_tools_surfaces_remote_connection_error(): ) with pytest.raises(MCPError, match="Client failed to connect"): + async with Client(proxy): + pass + + +async def test_proxy_list_tools_surfaces_remote_connection_error(): + port = find_available_port() + proxy = create_proxy( + StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), + provider_error_strategy="raise", + ) + + with pytest.raises(RuntimeError, match="Client failed to connect"): await proxy.list_tools() async def test_proxy_list_tools_client_surfaces_remote_connection_error(): - """Connecting succeeds and the first component operation reports the backend. - - `ProxyProvider._list_tools` normalizes the raw transport failure into the - `MCPError("Client failed to connect...")` this test expects. - """ port = find_available_port() proxy = create_proxy( StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), @@ -396,26 +371,22 @@ class TestTools: async def test_call_tool_result_same_as_original( self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy ): - # proxy_server's backend is pinned to legacy (see its fixture docstring); - # match the front so a real tool call doesn't cross eras. async with Client(fastmcp_server) as original_client: result = await original_client.call_tool("greet", {"name": "Alice"}) - async with Client(proxy_server, mode="legacy") as proxy_client: + async with Client(proxy_server) as proxy_client: proxy_result = await proxy_client.call_tool("greet", {"name": "Alice"}) assert result.content == proxy_result.content assert result.data == proxy_result.data async def test_call_tool_calls_tool(self, proxy_server): - # See proxy_server fixture docstring: its backend is pinned to legacy. - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: proxy_result = await client.call_tool("add", {"a": 1, "b": 2}) assert proxy_result.data == 3 async def test_error_tool_raises_error(self, proxy_server): - # See proxy_server fixture docstring: its backend is pinned to legacy. with pytest.raises(ToolError, match="This is a test error"): - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: await client.call_tool("error_tool", {}) async def test_error_tool_with_image_content(self, proxy_server): @@ -481,8 +452,7 @@ class TestTools: meta={"custom_key": "custom_value", "processed": True}, ) - # See proxy_server fixture docstring: its backend is pinned to legacy. - async with Client(proxy_server, mode="legacy") as client: + async with Client(proxy_server) as client: result = await client.call_tool("tool_with_meta", {"value": "test"}) assert isinstance(result.content[0], TextContent) @@ -864,12 +834,7 @@ class TestPrompts: result = await client.get_prompt("welcome", {"name": "Alice"}) async with Client(proxy_server) as client: proxy_result = await client.get_prompt("welcome", {"name": "Alice"}) - # Each server stamps its own `serverInfo` into `_meta` (spec #3002), so - # the proxy's stamp naturally differs from the origin's. Compare the - # relayed payload. - assert proxy_result.model_copy( - update={"meta": user_meta(proxy_result.meta)} - ) == result.model_copy(update={"meta": user_meta(result.meta)}) + assert proxy_result == result async def test_render_prompt_calls_prompt(self, proxy_server): async with Client(proxy_server) as client: @@ -923,11 +888,8 @@ class TestPrompts: async with Client(proxy_server) as client: proxy_result = await client.get_prompt("image_prompt") - # The proxy relays the original payload; only the per-server - # `serverInfo` `_meta` stamp differs. - assert proxy_result.model_copy( - update={"meta": user_meta(proxy_result.meta)} - ) == result.model_copy(update={"meta": user_meta(result.meta)}) + # The proxy result should match the original exactly + assert proxy_result == result # Verify the image content is preserved as ImageContent, not JSON text assert isinstance(proxy_result.messages[1].content, mcp_types.ImageContent) assert proxy_result.messages[1].content.data == "iVBORw0KGgoAAAANSUhEUg==" @@ -1190,393 +1152,3 @@ class TestProxySpanAttributes: assert all(v is not None for v in attrs.values()), ( f"OpenTelemetry rejects None attribute values; got {attrs!r}" ) - - -class TestProxyOutputSchemaEnforcement: - """A proxy relays tool results; it does not police the backend's schema. - - `ClientSession.call_tool` validates structured content against the output - schema the backend advertised. For a proxy that check is misplaced: it - turns a backend's schema bug into a proxy error and hides the real - response from the client that actually consumes it. - """ - - @pytest.fixture - def backend_violating_its_schema(self) -> FastMCP: - mcp = FastMCP("SchemaViolator") - schema = { - "type": "object", - "properties": {"status": {"enum": ["ok", "error"]}}, - "required": ["status"], - } - - @mcp.tool(output_schema=schema) - def undeclared_status() -> dict: - return {"status": "weird"} - - @mcp.tool(output_schema=schema) - def declared_status() -> dict: - return {"status": "ok"} - - return mcp - - async def _call_without_validating(self, server: FastMCP, tool: str): - """Call through a client that does not enforce the schema itself.""" - # This proxy's backend is built via `ProxyProvider(lambda: ProxyClient(...))` - # directly rather than through `create_proxy`'s era-mirroring factory, so it - # stays pinned to `ProxyClient`'s own default of `mode="legacy"` regardless - # of the front era (see the `proxy_server` fixture docstring above). - client = Client(server, mode="legacy") - client._transport_options = TransportOptions( - session_class=_ForwardingClientSession - ) - async with client: - return await client.call_tool_mcp(tool, {}) - - async def test_proxy_forwards_result_violating_backend_schema( - self, backend_violating_its_schema - ): - proxy = FastMCP("Proxy") - proxy.add_provider( - ProxyProvider(lambda: ProxyClient(backend_violating_its_schema)) - ) - - result = await self._call_without_validating(proxy, "undeclared_status") - - assert result.is_error is False - assert result.structured_content == {"status": "weird"} - - async def test_proxy_forwards_conforming_result_unchanged( - self, backend_violating_its_schema - ): - proxy = FastMCP("Proxy") - proxy.add_provider( - ProxyProvider(lambda: ProxyClient(backend_violating_its_schema)) - ) - - result = await self._call_without_validating(proxy, "declared_status") - - assert result.is_error is False - assert result.structured_content == {"status": "ok"} - - async def test_end_client_still_enforces_the_schema( - self, backend_violating_its_schema - ): - """Skipping validation in the proxy doesn't disarm the real client.""" - proxy = FastMCP("Proxy") - proxy.add_provider( - ProxyProvider(lambda: ProxyClient(backend_violating_its_schema)) - ) - - # `ProxyClient(backend_violating_its_schema)` above is pinned to legacy - # (see `_call_without_validating`'s comment); match the front here too. - async with Client(proxy, mode="legacy") as client: - with pytest.raises(RuntimeError, match="Invalid structured content"): - await client.call_tool_mcp("undeclared_status", {}) - - async def test_direct_client_still_enforces_the_schema( - self, backend_violating_its_schema - ): - """The behavior change is scoped to proxies, not clients generally.""" - async with Client(backend_violating_its_schema) as client: - with pytest.raises(RuntimeError, match="Invalid structured content"): - await client.call_tool_mcp("undeclared_status", {}) - - async def test_proxied_calls_do_not_refetch_the_backend_tool_list(self): - """Validation used to force a `tools/list` on every proxied call. - - The proxy builds a fresh client per request, so the SDK's output-schema - cache was always cold and each call paid an extra backend round trip. - """ - counts = {"list": 0, "call": 0} - - class CountingMiddleware(Middleware): - async def on_list_tools(self, context, call_next): - counts["list"] += 1 - return await call_next(context) - - async def on_call_tool(self, context, call_next): - counts["call"] += 1 - return await call_next(context) - - backend = FastMCP("Backend") - backend.add_middleware(CountingMiddleware()) - - @backend.tool( - output_schema={ - "type": "object", - "properties": {"n": {"type": "integer"}}, - "required": ["n"], - } - ) - def echo(n: int) -> dict: - return {"n": n} - - proxy = FastMCP("Proxy") - proxy.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - - # `ProxyClient(backend)` above is pinned to legacy (see - # `_call_without_validating`'s comment); match the front here too. - async with Client(proxy, mode="legacy") as client: - await client.call_tool("echo", {"n": 1}) - lists_after_first = counts["list"] - - for n in range(2, 5): - await client.call_tool("echo", {"n": n}) - - assert counts["call"] == 4 - assert counts["list"] == lists_after_first - - -class TestProxySettingsAreNotSharedBetweenClients: - """Proxy connection settings belong to the client, not to the transport. - - Configuring a shared transport in place used to leak proxy behavior into - unrelated clients — including header forwarding, which would send the - caller's credentials to a server the user never meant to authorize. - """ - - def test_building_a_proxy_client_does_not_reconfigure_a_shared_transport(self): - shared = StreamableHttpTransport("http://example.com/mcp/") - plain = Client(shared) - - ProxyClient(shared) - - assert plain._transport_options is None - - def test_proxy_client_carries_its_own_options(self): - proxy_client = ProxyClient(StreamableHttpTransport("http://example.com/mcp/")) - - options = proxy_client._transport_options - assert options is not None - assert options.forward_incoming_headers is True - assert options.session_class is _ForwardingClientSession - - def test_options_survive_the_per_request_client_copy(self): - """The proxy builds a fresh client per request via `new()`.""" - proxy_client = ProxyClient(StreamableHttpTransport("http://example.com/mcp/")) - - assert proxy_client.new()._transport_options is proxy_client._transport_options - - def test_a_user_supplied_client_is_not_reconfigured(self): - """`create_proxy(client)` must not change how the caller's client behaves.""" - user_client = Client(StreamableHttpTransport("http://example.com/mcp/")) - - create_proxy(user_client) - - assert user_client._transport_options is None - - -class TestProxyForwardingAppliesToEveryBackendClient: - """Every path that builds a proxy backend gets the forwarding session. - - `create_proxy` accepts plain Clients and MCPConfigs, none of which route - through `ProxyClient.__init__`, so configuring only that constructor would - leave those forms still rejecting backend results. - """ - - @pytest.fixture - def backend(self) -> FastMCP: - mcp = FastMCP("SchemaViolator") - - @mcp.tool( - output_schema={ - "type": "object", - "properties": {"status": {"enum": ["ok", "error"]}}, - "required": ["status"], - } - ) - def status() -> dict: - return {"status": "weird"} - - return mcp - - async def _forwarded( - self, server: FastMCP, tool: str = "status", mode: str = "auto" - ): - # `mode` follows the proxy backend's era: a plain Client or single-server - # config connects the backend directly, so it mirrors the front's auto - # era. A multi-server config instead mounts a router with a - # StatefulProxyClient per configured server leg — an already-constructed - # ProxyClient subclass, same as the `proxy_server` fixture above, pinned - # to `mode="legacy"` regardless of the front. - client = Client(server, mode=mode) - client._transport_options = TransportOptions( - session_class=_ForwardingClientSession - ) - async with client: - return await client.call_tool_mcp(tool, {}) - - async def test_plain_client_target_forwards(self, backend): - result = await self._forwarded(create_proxy(Client(backend))) - - assert result.is_error is False - assert result.structured_content == {"status": "weird"} - - async def test_single_server_config_target_forwards(self, backend): - port = find_available_port() - async with run_server_async(backend, port=port): - config = MCPConfig.from_dict( - {"mcpServers": {"a": {"url": f"http://127.0.0.1:{port}/mcp/"}}} - ) - result = await self._forwarded(create_proxy(Client(config))) - - assert result.is_error is False - assert result.structured_content == {"status": "weird"} - - async def test_multi_server_config_target_forwards(self, backend): - port = find_available_port() - async with run_server_async(backend, port=port): - url = f"http://127.0.0.1:{port}/mcp/" - config = MCPConfig.from_dict( - {"mcpServers": {"a": {"url": url}, "b": {"url": url}}} - ) - result = await self._forwarded( - create_proxy(Client(config)), "a_status", mode="legacy" - ) - - assert result.is_error is False - assert result.structured_content == {"status": "weird"} - - -class TestProxyModernEraInstructions: - """Upstream instructions must reach a client on the modern era too.""" - - async def test_proxy_forwards_upstream_instructions_on_modern_era(self): - upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123") - proxy = create_proxy(upstream, name="proxy") - - async with Client(proxy, mode="auto") as client: - assert client.protocol_version in MODERN_PROTOCOL_VERSIONS - assert client.session.instructions == "USE_THIS_MARKER_123" - - async def test_proxy_own_instructions_take_precedence_on_modern_era(self): - upstream = FastMCP(name="upstream", instructions="upstream instructions") - proxy = create_proxy(upstream, name="proxy", instructions="proxy instructions") - - async with Client(proxy, mode="auto") as client: - assert client.session.instructions == "proxy instructions" - - async def test_proxy_instructions_none_when_upstream_has_none_on_modern_era(self): - upstream = FastMCP(name="upstream") - proxy = create_proxy(upstream, name="proxy") - - async with Client(proxy, mode="auto") as client: - assert client.session.instructions is None - - -class TestProxyProviderTransportErrors: - """A dead backend must surface as an MCPError, not a raw transport error.""" - - @pytest.fixture - def unreachable_provider(self) -> ProxyProvider: - port = find_available_port() - return ProxyProvider( - lambda: ProxyClient(f"http://127.0.0.1:{port}/mcp/"), - cache_ttl=0, - ) - - @pytest.mark.parametrize( - "method", - ["_list_tools", "_list_resources", "_list_resource_templates", "_list_prompts"], - ) - async def test_list_method_wraps_connection_failure( - self, unreachable_provider: ProxyProvider, method: str - ): - with pytest.raises(MCPError): - await getattr(unreachable_provider, method)() - - @pytest.mark.parametrize( - "method", - ["_list_tools", "_list_resources", "_list_resource_templates", "_list_prompts"], - ) - async def test_list_method_wraps_raw_transport_error(self, method: str): - """A raw transport error raised mid-call is normalized, not leaked.""" - - def exploding_factory() -> Client: - raise httpx2.ConnectError("backend refused the connection") - - provider = ProxyProvider(exploding_factory, cache_ttl=0) - with pytest.raises(MCPError, match="backend refused the connection"): - await getattr(provider, method)() - - @pytest.mark.parametrize("mode", ["legacy", "auto"]) - async def test_connection_error_reaches_client_on_both_eras(self, mode: str): - """The actual defect: the modern era masked the connection failure. - - An unwrapped `RuntimeError` reaching the modern wire boundary is - replaced with a generic "Internal server error", so a client on the - newer protocol could not tell a dead backend from a server bug. On the - legacy era the same exception reached the wire as `str(exc)`, which is - why nothing caught this while tests pinned the older version. - """ - port = find_available_port() - proxy = create_proxy( - StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), - provider_error_strategy="raise", - ) - - with pytest.raises(MCPError, match="Client failed to connect"): - async with Client(proxy, mode=mode) as client: - await client.list_tools() - - -async def test_proxy_preserves_x_mcp_header_annotation(): - """A proxy re-advertises a backend tool's `x-mcp-header` annotation (SEP-2243). - - The routing headers are per-hop: the SDK client regenerates them on each - HTTP request. For `Mcp-Param-*` to be emitted on the proxy->backend hop (and - on the caller->proxy hop), the proxy must carry the backend's `x-mcp-header` - schema annotation through to its own advertised tool schema. - """ - from typing import Annotated - - from pydantic import Field - - backend = FastMCP("Backend") - - @backend.tool - def route( - tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})], - ) -> str: - return tenant - - proxy = create_proxy(backend) - async with Client(proxy) as client: - tools = await client.list_tools() - - (tool,) = [t for t in tools if t.name == "route"] - assert tool.input_schema["properties"]["tenant"]["x-mcp-header"] == "Tenant" - - -async def test_proxy_forwards_mcp_param_header_to_modern_http_backend(): - """A proxy in front of a modern Streamable-HTTP backend routes an annotated call (SEP-2243). - - A modern backend validates that an `x-mcp-header` argument is mirrored into an - `Mcp-Param-*` header and rejects the call with `HEADER_MISMATCH` when it is - missing. The SDK client caches the annotation map on `list_tools`, but a - proxied `tools/call` goes straight to `call_tool` on a fresh backend session, - so the proxy must seed the map itself. This exercises the real validating HTTP - hop end to end. - """ - from typing import Annotated - - from pydantic import Field - - backend = FastMCP("Backend") - - @backend.tool - def route( - tenant: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Tenant"})], - ) -> str: - return f"routed:{tenant}" - - async with run_server_async(backend, transport="http") as url: - # mode="auto" negotiates the modern protocol with the HTTP backend, so - # the proxy->backend hop is the validating one. (ProxyClient defaults to - # legacy, which neither emits nor validates these headers.) - proxy = create_proxy(ProxyClient(StreamableHttpTransport(url), mode="auto")) - async with Client(proxy) as client: - result = await client.call_tool("route", {"tenant": "acme"}) - - assert result.data == "routed:acme" diff --git a/tests/server/providers/proxy/test_server_metadata.py b/tests/server/providers/proxy/test_server_metadata.py deleted file mode 100644 index 802235c95..000000000 --- a/tests/server/providers/proxy/test_server_metadata.py +++ /dev/null @@ -1,637 +0,0 @@ -"""Server metadata forwarding across proxy protocol eras.""" - -from itertools import product -from typing import Any, Literal, TypeVar - -import mcp_types -import pytest -from mcp import MCPError -from mcp_types.version import MODERN_PROTOCOL_VERSIONS - -from fastmcp import Client, FastMCP, FastMCPDeprecationWarning -from fastmcp.client.logging import LogMessage -from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.server import create_proxy -from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.server.providers.proxy import ( - FastMCPProxy, - ProxyClient, - ProxyInitializeMiddleware, - ProxyMetadataMiddleware, - ProxyProvider, - StatefulProxyClient, -) -from fastmcp.utilities.http import find_available_port - -ResultT = TypeVar("ResultT", bound=mcp_types.Result) - -UPSTREAM_INFO = mcp_types.Implementation( - name="upstream", - title="Upstream title", - version="1.2.3", - description="Upstream description", - website_url="https://upstream.example.com", - icons=[mcp_types.Icon(src="https://upstream.example.com/icon.png")], -) - - -class UpstreamMetadataMiddleware(Middleware): - """Advertise metadata that differs from the gateway's own claims.""" - - def __init__(self, server_info: mcp_types.Implementation = UPSTREAM_INFO) -> None: - self.server_info = server_info - - def _updates(self, result: mcp_types.Result) -> dict[str, Any]: - meta = { - **(result.meta or {}), - mcp_types.PROTOCOL_VERSION_META_KEY: "upstream-version", - mcp_types.CLIENT_INFO_META_KEY: {"name": "upstream-client"}, - mcp_types.CLIENT_CAPABILITIES_META_KEY: {"upstream": True}, - "com.example/upstream": {"enabled": True}, - "com.example/shared": "upstream", - } - updates: dict[str, Any] = { - "instructions": "upstream instructions", - "meta": meta, - } - if isinstance(result, mcp_types.InitializeResult): - updates.update( - server_info=self.server_info, - capabilities=mcp_types.ServerCapabilities( - experimental={"upstream": {"claimed": True}} - ), - ) - else: - meta[mcp_types.SERVER_INFO_META_KEY] = self.server_info.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - updates.update( - ttl_ms=91_000, - cache_scope="public", - capabilities=mcp_types.ServerCapabilities( - experimental={"upstream": {"claimed": True}} - ), - ) - return updates - - async def on_initialize( - self, - context: MiddlewareContext[mcp_types.InitializeRequest], - call_next: CallNext[ - mcp_types.InitializeRequest, mcp_types.InitializeResult | None - ], - ) -> mcp_types.InitializeResult | None: - result = await call_next(context) - assert result is not None - return result.model_copy(update=self._updates(result)) - - async def on_discover( - self, - context: MiddlewareContext[mcp_types.DiscoverRequest], - call_next: CallNext[ - mcp_types.DiscoverRequest, - mcp_types.DiscoverResult | dict[str, Any], - ], - ) -> mcp_types.DiscoverResult | dict[str, Any]: - result = await call_next(context) - if not isinstance(result, mcp_types.DiscoverResult): - return result - return result.model_copy(update=self._updates(result)) - - -class FrontendMetadataMiddleware(Middleware): - """Set frontend values that must win over the upstream on collision.""" - - def _update(self, result: ResultT) -> ResultT: - return result.model_copy( - update={ - "meta": { - **(result.meta or {}), - "com.example/shared": "frontend", - "com.example/frontend": {"enabled": True}, - }, - } - ) - - async def on_initialize( - self, - context: MiddlewareContext[mcp_types.InitializeRequest], - call_next: CallNext[ - mcp_types.InitializeRequest, mcp_types.InitializeResult | None - ], - ) -> mcp_types.InitializeResult | None: - result = await call_next(context) - assert result is not None - return self._update(result) - - async def on_discover( - self, - context: MiddlewareContext[mcp_types.DiscoverRequest], - call_next: CallNext[ - mcp_types.DiscoverRequest, - mcp_types.DiscoverResult | dict[str, Any], - ], - ) -> mcp_types.DiscoverResult | dict[str, Any]: - result = await call_next(context) - if not isinstance(result, mcp_types.DiscoverResult): - return result - return self._update(result) - - -def make_upstream() -> FastMCP: - return FastMCP("unmodified-upstream", middleware=[UpstreamMetadataMiddleware()]) - - -def make_gateway( - upstream: FastMCP, - *, - backend_mode: str, - identity: Literal["proxy", "upstream"] = "proxy", - instructions: str | None = None, - frontend_metadata: bool = False, -) -> FastMCP: - provider = ProxyProvider(lambda: ProxyClient(upstream, mode=backend_mode)) - metadata = ProxyMetadataMiddleware(provider, identity=identity) - middleware: list[Middleware] = [metadata] - if frontend_metadata: - middleware.append(FrontendMetadataMiddleware()) - gateway = FastMCP( - "gateway", - version="9.8.7", - instructions=instructions, - providers=[provider], - middleware=middleware, - cache_ttl=7, - cache_scope="private", - ) - return gateway - - -@pytest.mark.parametrize( - ("frontend_mode", "backend_mode"), - list(product(("legacy", "auto"), repeat=2)), -) -async def test_forwards_metadata_across_all_protocol_era_combinations( - frontend_mode: str, backend_mode: str -): - gateway = make_gateway(make_upstream(), backend_mode=backend_mode) - - async with Client(gateway, mode=frontend_mode) as client: - result = client.session.initialize_result or client.session.discover_result - assert result is not None - assert client.instructions == "upstream instructions" - assert client.server_info is not None - assert client.server_info.name == "gateway" - assert result.meta is not None - assert result.meta["com.example/upstream"] == {"enabled": True} - for key in ( - mcp_types.PROTOCOL_VERSION_META_KEY, - mcp_types.CLIENT_INFO_META_KEY, - mcp_types.CLIENT_CAPABILITIES_META_KEY, - ): - assert key not in result.meta - stamped_info = result.meta.get(mcp_types.SERVER_INFO_META_KEY) - assert result.capabilities.experimental is None - - if isinstance(result, mcp_types.InitializeResult): - assert result.protocol_version not in MODERN_PROTOCOL_VERSIONS - assert stamped_info is None - else: - assert stamped_info is not None - assert stamped_info["name"] == "gateway" - assert result.supported_versions == list(MODERN_PROTOCOL_VERSIONS) - assert result.ttl_ms == 7_000 - assert result.cache_scope == "private" - assert result.result_type == "complete" - - -@pytest.mark.parametrize("frontend_mode", ["legacy", "auto"]) -@pytest.mark.parametrize("identity", ["proxy", "upstream"]) -async def test_identity_policy_forwards_full_implementation( - frontend_mode: str, identity: Literal["proxy", "upstream"] -): - gateway = make_gateway(make_upstream(), backend_mode="auto", identity=identity) - - async with Client(gateway, mode=frontend_mode) as client: - assert client.server_info is not None - if identity == "proxy": - assert client.server_info.name == "gateway" - assert client.server_info.version == "9.8.7" - else: - assert client.server_info == UPSTREAM_INFO - result = client.session.initialize_result or client.session.discover_result - assert result is not None - if isinstance(result, mcp_types.InitializeResult): - assert mcp_types.SERVER_INFO_META_KEY not in (result.meta or {}) - else: - assert result.meta is not None - assert result.meta[mcp_types.SERVER_INFO_META_KEY]["name"] == "upstream" - - -@pytest.mark.parametrize("frontend_mode", ["legacy", "auto"]) -async def test_frontend_values_take_precedence(frontend_mode: str): - gateway = make_gateway( - make_upstream(), - backend_mode="auto", - instructions="frontend instructions", - frontend_metadata=True, - ) - - async with Client(gateway, mode=frontend_mode) as client: - result = client.session.initialize_result or client.session.discover_result - assert result is not None - assert client.instructions == "frontend instructions" - assert result.meta is not None - assert result.meta["com.example/shared"] == "frontend" - assert result.meta["com.example/frontend"] == {"enabled": True} - assert result.meta["com.example/upstream"] == {"enabled": True} - - -async def test_forwards_backend_logs_while_reading_metadata(): - messages: list[str] = [] - - class LogOnInitialize(Middleware): - async def on_initialize( - self, - context: MiddlewareContext[mcp_types.InitializeRequest], - call_next: CallNext[ - mcp_types.InitializeRequest, mcp_types.InitializeResult | None - ], - ) -> mcp_types.InitializeResult | None: - result = await call_next(context) - assert context.fastmcp_context is not None - await context.fastmcp_context.log("metadata connection") - return result - - async def capture_log(message: LogMessage) -> None: - messages.append(message.data["msg"]) - - upstream = FastMCP("upstream", middleware=[LogOnInitialize()]) - proxy = create_proxy(upstream) - - async with Client(proxy, mode="legacy", log_handler=capture_log): - pass - - assert messages == ["metadata connection"] - - -async def test_pinned_client_uses_prior_discover_metadata(): - prior_info = mcp_types.Implementation(name="prior", version="1.0") - prior = mcp_types.DiscoverResult( - supported_versions=[MODERN_PROTOCOL_VERSIONS[0]], - capabilities=mcp_types.ServerCapabilities(), - instructions="prior instructions", - meta={ - mcp_types.SERVER_INFO_META_KEY: prior_info.model_dump( - by_alias=True, mode="json" - ), - "com.example/prior": True, - }, - ) - provider = ProxyProvider( - lambda: ProxyClient( - make_upstream(), - mode=MODERN_PROTOCOL_VERSIONS[0], - prior_discover=prior, - ) - ) - gateway = FastMCP( - "gateway", - providers=[provider], - middleware=[ProxyMetadataMiddleware(provider, identity="upstream")], - ) - - async with Client(gateway, mode="auto") as client: - result = client.session.discover_result - assert result is not None - assert client.instructions == "prior instructions" - assert client.server_info == prior_info - assert result.meta is not None - assert result.meta["com.example/prior"] is True - - -async def test_connected_pinned_client_probes_without_adopting_metadata(): - version = MODERN_PROTOCOL_VERSIONS[0] - upstream = make_upstream() - async with Client(upstream, mode=version) as backend_client: - assert backend_client.instructions is None - proxy = create_proxy(backend_client, identity="upstream") - - async with Client(proxy, mode="auto") as client: - result = client.session.discover_result - assert result is not None - assert client.instructions == "upstream instructions" - assert client.server_info == UPSTREAM_INFO - assert result.meta is not None - assert result.meta["com.example/upstream"] == {"enabled": True} - - assert backend_client.instructions is None - - -async def test_invalid_upstream_discovery_metadata_is_ignored( - monkeypatch: pytest.MonkeyPatch, -): - version = MODERN_PROTOCOL_VERSIONS[0] - - async def invalid_discover(_version: str) -> dict[str, Any]: - return { - "resultType": "complete", - "supportedVersions": [version], - "capabilities": [], - } - - async with ProxyClient(make_upstream(), mode=version) as backend_client: - monkeypatch.setattr(backend_client.session, "send_discover", invalid_discover) - proxy = create_proxy(backend_client) - - async with Client(proxy, mode="auto") as client: - assert client.server_info is not None - assert client.server_info.name == proxy.name - assert await client.list_tools() == [] - - -async def test_invalid_backend_client_negotiation_is_not_ignored(): - version = MODERN_PROTOCOL_VERSIONS[0] - prior = mcp_types.DiscoverResult( - supported_versions=["2099-01-01"], - capabilities=mcp_types.ServerCapabilities(), - ) - provider = ProxyProvider( - lambda: ProxyClient( - make_upstream(), - mode=version, - prior_discover=prior, - ) - ) - gateway = FastMCP( - "gateway", - providers=[provider], - middleware=[ProxyMetadataMiddleware(provider)], - ) - - with pytest.raises(MCPError): - async with Client(gateway, mode="auto"): - pass - - -async def test_unrelated_client_validation_error_is_not_ignored(): - class InvalidClient(ProxyClient): - async def __aenter__(self) -> ProxyClient: - mcp_types.Implementation.model_validate({}) - return self - - provider = ProxyProvider(lambda: InvalidClient(make_upstream())) - gateway = FastMCP( - "gateway", - providers=[provider], - middleware=[ProxyMetadataMiddleware(provider)], - ) - - with pytest.raises(MCPError): - async with Client(gateway, mode="auto"): - pass - - -@pytest.mark.parametrize("mode", ["legacy", "auto"]) -async def test_forwarded_metadata_does_not_alias_connected_backend(mode: str): - backend_info = mcp_types.Implementation(name="shared-backend", version="1.0") - upstream = FastMCP( - "upstream", - middleware=[UpstreamMetadataMiddleware(backend_info)], - ) - - class MutateForwardedMetadata(Middleware): - def _mutate(self, result: ResultT) -> ResultT: - assert result.meta is not None - nested = result.meta["com.example/upstream"] - assert isinstance(nested, dict) - nested["enabled"] = False - if isinstance(result, mcp_types.InitializeResult): - result.server_info.name = "frontend mutation" - else: - server_info = result.meta[mcp_types.SERVER_INFO_META_KEY] - assert isinstance(server_info, dict) - server_info["name"] = "frontend mutation" - return result - - async def on_initialize(self, context, call_next): - result = await call_next(context) - assert result is not None - return self._mutate(result) - - async def on_discover(self, context, call_next): - result = await call_next(context) - if not isinstance(result, mcp_types.DiscoverResult): - return result - return self._mutate(result) - - async with Client(upstream, mode=mode) as backend_client: - provider = ProxyProvider(lambda: backend_client) - gateway = FastMCP( - "gateway", - providers=[provider], - middleware=[ - MutateForwardedMetadata(), - ProxyMetadataMiddleware(provider, identity="upstream"), - ], - ) - - async with Client(gateway, mode=mode): - pass - - backend_result = ( - backend_client.session.initialize_result - or backend_client.session.discover_result - ) - assert backend_result is not None - assert backend_result.meta is not None - assert backend_result.meta["com.example/upstream"] == {"enabled": True} - assert backend_client.server_info == backend_info - - -async def test_disconnected_pinned_client_is_not_cloned(): - class UnclonableProxyClient(ProxyClient): - def new(self) -> ProxyClient: - raise AssertionError("metadata client must not be cloned") - - version = MODERN_PROTOCOL_VERSIONS[0] - provider = ProxyProvider( - lambda: UnclonableProxyClient(make_upstream(), mode=version) - ) - gateway = FastMCP( - "gateway", - providers=[provider], - middleware=[ProxyMetadataMiddleware(provider, identity="upstream")], - ) - - async with Client(gateway, mode="auto") as client: - assert client.instructions == "upstream instructions" - assert client.server_info == UPSTREAM_INFO - - -async def test_stateful_pinned_metadata_uses_registered_client_lifecycle(): - created: list[StatefulProxyClient] = [] - - class TrackingStatefulProxyClient(StatefulProxyClient): - def new(self) -> StatefulProxyClient: - client = super().new() - created.append(client) - return client - - version = MODERN_PROTOCOL_VERSIONS[0] - stateful_client = TrackingStatefulProxyClient(make_upstream(), mode=version) - proxy = FastMCPProxy( - name="stateful-proxy", - client_factory=stateful_client.new_stateful, - identity="upstream", - ) - - async with Client(proxy, mode="auto") as client: - assert client.instructions == "upstream instructions" - assert client.server_info == UPSTREAM_INFO - - assert len(created) == 1 - assert not created[0].is_connected() - - -@pytest.mark.parametrize("frontend_mode", ["legacy", "auto"]) -@pytest.mark.parametrize("async_factory", [False, True]) -@pytest.mark.parametrize("error_kind", ["runtime", "mcp"]) -async def test_client_factory_errors_are_not_swallowed( - frontend_mode: str, - async_factory: bool, - error_kind: Literal["runtime", "mcp"], -): - def factory_error() -> Exception: - if error_kind == "mcp": - return MCPError( - code=mcp_types.INTERNAL_ERROR, - message="broken client factory", - ) - return RuntimeError("broken client factory") - - def broken_factory() -> Client: - raise factory_error() - - async def broken_async_factory() -> Client: - raise factory_error() - - factory = broken_async_factory if async_factory else broken_factory - provider = ProxyProvider(factory) - gateway = FastMCP( - "gateway", - providers=[provider], - middleware=[ProxyMetadataMiddleware(provider)], - ) - - with pytest.raises(MCPError): - async with Client(gateway, mode=frontend_mode): - pass - - -@pytest.mark.parametrize("frontend_mode", ["legacy", "auto"]) -async def test_unavailable_backend_does_not_block_connection(frontend_mode: str): - port = find_available_port() - provider = ProxyProvider( - lambda: ProxyClient( - StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), mode="auto" - ), - cache_ttl=0, - ) - gateway = FastMCP( - "available-gateway", - providers=[provider], - middleware=[ProxyMetadataMiddleware(provider)], - ) - gateway.provider_error_strategy = "raise" - - async with Client(gateway, mode=frontend_mode) as client: - assert client.server_info is not None - assert client.server_info.name == "available-gateway" - with pytest.raises(MCPError, match="Client failed to connect"): - await client.list_tools() - - -async def test_extension_owned_discovery_result_bypasses_metadata_forwarding(): - factory_called = False - - def broken_factory() -> Client: - nonlocal factory_called - factory_called = True - raise RuntimeError("metadata should not be read") - - async def custom_discover(_ctx, _params): - return { - "resultType": "com.example/custom", - "payload": {"enabled": True}, - } - - provider = ProxyProvider(broken_factory) - gateway = FastMCP( - "extension-gateway", - middleware=[ProxyMetadataMiddleware(provider)], - ) - gateway._mcp_server.add_request_handler( - "server/discover", mcp_types.RequestParams, custom_discover - ) - - version = MODERN_PROTOCOL_VERSIONS[0] - async with Client(gateway, mode=version) as client: - result = await client.session.send_discover(version) - - assert isinstance(result, dict) - assert result["payload"] == {"enabled": True} - assert not factory_called - - -def test_gateway_construction_does_not_create_backend_client(): - calls = 0 - - def client_factory() -> ProxyClient: - nonlocal calls - calls += 1 - return ProxyClient(make_upstream()) - - provider = ProxyProvider(client_factory) - FastMCP( - "lazy-gateway", - providers=[provider], - middleware=[ProxyMetadataMiddleware(provider)], - ) - - assert calls == 0 - - -async def test_proxy_initialize_middleware_preserves_legacy_behavior(): - upstream = FastMCP("upstream", instructions="legacy instructions") - - def client_factory() -> ProxyClient: - return ProxyClient(upstream) - - proxy = FastMCPProxy(name="compatibility-proxy", client_factory=client_factory) - - with pytest.warns( - FastMCPDeprecationWarning, - match="`ProxyInitializeMiddleware` is deprecated", - ): - middleware = ProxyInitializeMiddleware(proxy) - - proxy.middleware = [middleware] - async with Client(proxy, mode="legacy") as client: - assert client.instructions == "legacy instructions" - async with Client(proxy, mode="auto") as client: - assert client.instructions is None - - assert middleware.proxy is proxy - - -async def test_fastmcp_proxy_uses_public_metadata_middleware(): - proxy = create_proxy(make_upstream(), name="convenience", identity="upstream") - - assert any( - isinstance(middleware, ProxyMetadataMiddleware) - for middleware in proxy.middleware - ) - async with Client(proxy, mode="auto") as client: - assert client.instructions == "upstream instructions" - assert client.server_info == UPSTREAM_INFO diff --git a/tests/server/providers/proxy/test_stateful_proxy_client.py b/tests/server/providers/proxy/test_stateful_proxy_client.py index 0bf832440..3ebb9d536 100644 --- a/tests/server/providers/proxy/test_stateful_proxy_client.py +++ b/tests/server/providers/proxy/test_stateful_proxy_client.py @@ -58,13 +58,6 @@ def fastmcp_server(): @pytest.fixture async def stateful_proxy_server(fastmcp_server: FastMCP): - # `StatefulProxyClient` is a `ProxyClient` subclass, so it inherits the same - # `mode="legacy"` default for a directly-constructed instance (see - # `TestProxyClientEraDefault` in test_proxy_client.py) — this backend isn't - # built through `create_proxy`'s era-mirroring factory, so it stays pinned - # regardless of the front era. Tests of handshake-only forwarding pin their - # front `Client` to `mode="legacy"` too: those server-initiated - # interactions do not exist on modern connections. client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server)) return FastMCPProxy(client_factory=client.new_stateful) @@ -102,12 +95,8 @@ class TestStatefulProxyClient: results["logger_b"] = message async with ( - Client( - stateful_proxy_server, mode="legacy", log_handler=log_handler_a - ) as client_a, - Client( - stateful_proxy_server, mode="legacy", log_handler=log_handler_b - ) as client_b, + Client(stateful_proxy_server, log_handler=log_handler_a) as client_a, + Client(stateful_proxy_server, log_handler=log_handler_b) as client_b, ): async with create_task_group() as tg: tg.start_soon( @@ -126,8 +115,7 @@ class TestStatefulProxyClient: async def test_stateful_proxy(self, stateful_proxy_server: FastMCP): """Test that the state shared across multiple calls for the same client (fixes #959).""" - # See stateful_proxy_server fixture: its backend is pinned to legacy. - async with Client(stateful_proxy_server, mode="legacy") as client: + async with Client(stateful_proxy_server) as client: with pytest.raises(ToolError, match="Value not found"): await client.call_tool("stateful_get", {}) @@ -138,8 +126,7 @@ class TestStatefulProxyClient: async def test_stateless_proxy(self, stateless_server: str): """Test that the state will not be shared across different calls, even if they are from the same client.""" - # See stateful_proxy_server fixture: its backend is pinned to legacy. - async with Client(stateless_server, mode="legacy") as client: + async with Client(stateless_server) as client: await client.call_tool("stateful_put", {"value": 1}) with pytest.raises(ToolError, match="Value not found"): @@ -167,9 +154,7 @@ class TestStatefulProxyClient: multi_proxy_mcp.mount(proxy_mcp_a, namespace="a") multi_proxy_mcp.mount(proxy_mcp_b, namespace="b") - # Both mounted backends are directly-constructed StatefulProxyClients - # (see stateful_proxy_server fixture note above), pinned to legacy. - async with Client(multi_proxy_mcp, mode="legacy") as client: + async with Client(multi_proxy_mcp) as client: result_a = await client.call_tool("a_tool_a", {}) result_b = await client.call_tool("b_tool_b", {}) assert result_a.data == "a" @@ -214,13 +199,10 @@ class TestStatefulProxyClient: return ElicitResult(action="accept", content=response_type(name="Alice")) # Run the proxy over HTTP so the transport uses - # related_request_id routing for server-initiated messages. Elicitation - # is a handshake-only back-channel feature, and the backend is a - # directly-constructed StatefulProxyClient pinned to legacy regardless - # (see stateful_proxy_server fixture note above) — pin the front to match. + # related_request_id routing for server-initiated messages. async with run_server_async(proxy) as proxy_url: async with Client( - proxy_url, mode="legacy", elicitation_handler=elicitation_handler + proxy_url, elicitation_handler=elicitation_handler ) as client: result1 = await client.call_tool("ask_name", {}) assert result1.data == "Hello, Alice!" diff --git a/tests/server/providers/test_base_provider.py b/tests/server/providers/test_base_provider.py index 035dec6d7..38db55e08 100644 --- a/tests/server/providers/test_base_provider.py +++ b/tests/server/providers/test_base_provider.py @@ -6,9 +6,9 @@ import pytest from fastmcp.server.providers.aggregate import AggregateProvider from fastmcp.server.providers.base import Provider +from fastmcp.server.tasks.config import TaskConfig from fastmcp.server.transforms import Namespace from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.tasks import TaskConfig class CustomTool(Tool): diff --git a/tests/server/providers/test_local_provider.py b/tests/server/providers/test_local_provider.py index 6096e4509..ebc74f90c 100644 --- a/tests/server/providers/test_local_provider.py +++ b/tests/server/providers/test_local_provider.py @@ -17,8 +17,8 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.prompts.base import Prompt from fastmcp.server.providers.local_provider import LocalProvider +from fastmcp.server.tasks import TaskConfig from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.tasks import TaskConfig class TestLocalProviderStorage: diff --git a/tests/server/providers/test_prefab_roundtrip.py b/tests/server/providers/test_prefab_roundtrip.py index 05897eef8..72946910f 100644 --- a/tests/server/providers/test_prefab_roundtrip.py +++ b/tests/server/providers/test_prefab_roundtrip.py @@ -8,46 +8,22 @@ single-server, namespaced mounts, and cross-server mounts. from __future__ import annotations +import json + import pytest from fastmcp import FastMCP, FastMCPApp -from fastmcp.exceptions import ToolError -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.server.middleware.tool_injection import ToolInjectionMiddleware -from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name -from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider -from fastmcp.server.transforms.search import RegexSearchTransform -from fastmcp.server.transforms.tool_transform import ToolTransform -from fastmcp.tools.base import Tool -from fastmcp.tools.tool_transform import ToolTransformConfig +from fastmcp.server.providers.addressing import hashed_backend_name prefab_ui = pytest.importorskip("prefab_ui") from prefab_ui.actions.mcp import CallTool # noqa: E402 from prefab_ui.components import Button, Column, Text # noqa: E402 -def _tool_refs(payload) -> list[str]: - """Every tool name the rendered UI would call, in document order.""" - refs: list[str] = [] - - def walk(node) -> None: - if isinstance(node, dict): - if node.get("action") == "toolCall" and isinstance(node.get("tool"), str): - refs.append(node["tool"]) - for value in node.values(): - walk(value) - elif isinstance(node, list): - for item in node: - walk(item) - - walk(payload) - return refs - - class TestSingleServerRoundTrip: - async def test_payload_carries_the_servers_own_tool_name(self): - """The renderer is handed a name that exists in this server's - tools/list, not the identity-addressed form.""" + async def test_ui_tool_serializes_hashed_peer_reference(self): + """The resolver converts a CallTool string reference to a hashed + name that appears in the tool result's structured_content.""" app = FastMCPApp("contacts") @app.tool() @@ -65,32 +41,13 @@ class TestSingleServerRoundTrip: result = await server.call_tool("contact_form", {}) assert result.structured_content is not None - assert _tool_refs(result.structured_content) == ["save_contact"] - async def test_payload_records_the_identity_behind_each_reference(self): - """The identity-addressed form survives alongside the rewritten name, - so an outer server can re-resolve it — or fall back to it.""" - app = FastMCPApp("contacts") - - @app.tool() - def save_contact(name: str) -> str: - return f"saved {name}" - - @app.ui() - def contact_form() -> Column: - return Column( - children=[Button(label="Save", on_click=CallTool(tool="save_contact"))] - ) - - server = FastMCP("Platform") - server.add_provider(app) - - result = await server.call_tool("contact_form", {}) - assert result.structured_content is not None - names = result.structured_content["_meta"]["fastmcp"]["toolNames"] - assert names == { - "save_contact": hashed_backend_name("contacts", "save_contact") - } + # The hashed name should appear somewhere in the serialized output. + sc_json = json.dumps(result.structured_content) + expected_hash = hashed_backend_name("contacts", "save_contact") + assert expected_hash in sc_json, ( + f"Expected {expected_hash!r} in structured_content but got: {sc_json[:200]}" + ) async def test_hashed_name_from_result_is_callable(self): """The hashed name that appears in structured_content actually @@ -174,470 +131,6 @@ class TestMountedServerRoundTrip: assert result.content[0].text == "saved Carol" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] -class TestProxiedServerRoundTrip: - """A gateway proxying an app-bearing backend. - - A proxy knows only what crossed the wire, so this is the topology that - breaks if app-only tools are filtered out of tools/list or if the - identity hash is stripped from meta. - """ - - @staticmethod - def _backend() -> FastMCP: - app = FastMCPApp("contacts") - - @app.tool() - def save(name: str) -> str: - return f"saved {name}" - - @app.ui() - def form() -> Text: - return Text(content="Form") - - backend = FastMCP("Backend") - backend.add_provider(app) - return backend - - async def test_app_only_tool_is_forwarded_through_a_proxy(self): - backend = self._backend() - gateway = FastMCP("Gateway") - gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - - names = [t.name for t in await gateway.list_tools()] - assert "save" in names - - async def test_identity_hash_survives_the_proxy(self): - backend = self._backend() - gateway = FastMCP("Gateway") - gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - - tool = next(t for t in await gateway.list_tools() if t.name == "save") - assert tool.meta is not None - assert tool.meta["fastmcp"]["tool_hash"] == hash_tool("contacts", "save") - - async def test_backend_tool_callable_by_hash_through_a_proxy(self): - backend = self._backend() - gateway = FastMCP("Gateway") - gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - - hashed_name = hashed_backend_name("contacts", "save") - result = await gateway.call_tool(hashed_name, {"name": "Dana"}) - assert result.content[0].text == "saved Dana" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_backend_tool_callable_through_a_namespaced_proxy(self): - backend = self._backend() - gateway = FastMCP("Gateway") - gateway.add_provider( - ProxyProvider(lambda: ProxyClient(backend)), namespace="up" - ) - - names = [t.name for t in await gateway.list_tools()] - assert "up_save" in names - - hashed_name = hashed_backend_name("contacts", "save") - result = await gateway.call_tool(hashed_name, {"name": "Erin"}) - assert result.content[0].text == "saved Erin" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_backend_tool_callable_through_chained_proxies(self): - backend = self._backend() - middle = FastMCP("Middle") - middle.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - top = FastMCP("Top") - top.add_provider(ProxyProvider(lambda: ProxyClient(middle))) - - hashed_name = hashed_backend_name("contacts", "save") - result = await top.call_tool(hashed_name, {"name": "Frank"}) - assert result.content[0].text == "saved Frank" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - -class TestLateBoundToolNames: - """The payload is re-addressed on the way out of every FastMCP server. - - Servers unwind innermost-first, so the outermost one rewrites last and its - names — the only ones a client can invoke — are what the renderer receives. - """ - - @staticmethod - def _app(marker: str = "x", app_name: str = "contacts") -> FastMCPApp: - app = FastMCPApp(app_name) - - @app.tool() - def save(name: str) -> str: - return f"[{marker}] saved {name}" - - @app.ui() - def form() -> Column: - return Column( - children=[Button(label="Save", on_click=CallTool(tool="save"))] - ) - - return app - - async def test_namespaced_server_emits_its_namespaced_name(self): - server = FastMCP("Platform") - server.add_provider(self._app(), namespace="crm") - - result = await server.call_tool("crm_form", {}) - assert _tool_refs(result.structured_content) == ["crm_save"] - - async def test_name_accumulates_through_nested_mounts(self): - inner = FastMCP("Inner") - inner.add_provider(self._app(), namespace="a") - mid = FastMCP("Mid") - mid.add_provider(inner, namespace="b") - top = FastMCP("Top") - top.add_provider(mid, namespace="c") - - result = await top.call_tool("c_b_a_form", {}) - assert _tool_refs(result.structured_content) == ["c_b_a_save"] - - async def test_gateway_emits_its_own_name_not_the_backends(self): - backend = FastMCP("Backend") - backend.add_provider(self._app()) - - gateway = FastMCP("Gateway") - gateway.add_provider( - ProxyProvider(lambda: ProxyClient(backend)), namespace="up" - ) - - result = await gateway.call_tool("up_form", {}) - assert _tool_refs(result.structured_content) == ["up_save"] - - async def test_emitted_name_is_callable_on_the_same_server(self): - """The whole point: what the renderer is told to call, it can call.""" - backend = FastMCP("Backend") - backend.add_provider(self._app(marker="be")) - - gateway = FastMCP("Gateway") - gateway.add_provider( - ProxyProvider(lambda: ProxyClient(backend)), namespace="up" - ) - - result = await gateway.call_tool("up_form", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref in [t.name for t in await gateway.list_tools()] - - clicked = await gateway.call_tool(ref, {"name": "alice"}) - assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - @pytest.mark.parametrize( - "transform_factory,expected_listing", - [ - ( - lambda: RegexSearchTransform(), - ["search_tools", "call_tool"], - ), - ( - lambda: CodeMode(), - ["search", "get_schema", "execute"], - ), - ], - ids=["tool-search", "code-mode"], - ) - async def test_survives_a_collapsed_catalog( - self, transform_factory, expected_listing - ): - """Tool search and code mode replace tools/list wholesale, so there is - no better name to bind to. The reference stays identity-addressed and - the hashed path still resolves it.""" - server = FastMCP("Platform") - server.add_provider(self._app(marker="cat")) - server.add_transform(transform_factory()) - - assert [t.name for t in await server.list_tools()] == expected_listing - - result = await server.call_tool("form", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == hashed_backend_name("contacts", "save") - - clicked = await server.call_tool(ref, {"name": "alice"}) - assert clicked.content[0].text == "[cat] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - @pytest.mark.parametrize( - "compose", - ["siblings", "nested", "prefixing-namespaces"], - ) - async def test_a_duplicated_app_is_not_bound(self, compose): - """One app composed twice leaves no fact in the listing saying which - copy a UI belongs to, so no name is bound and the reference keeps its - identity. Covers copies as siblings, nested inside one subtree, and - under namespaces that prefix one another. - """ - if compose == "nested": - inner = FastMCP("Inner") - inner.add_provider(self._app(marker="A"), namespace="a") - inner.add_provider(self._app(marker="B"), namespace="b") - server = FastMCP("Top") - server.add_provider(inner, namespace="outer") - entry = "outer_a_form" - else: - second = "a_form" if compose == "prefixing-namespaces" else "b" - server = FastMCP("Top") - server.add_provider(self._app(marker="A"), namespace="a") - server.add_provider(self._app(marker="B"), namespace=second) - entry = "a_form" - - result = await server.call_tool(entry, {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == hashed_backend_name("contacts", "save") - - async def test_a_duplicated_app_reports_the_ambiguity(self): - """The unbound reference must fail with a message that names the real - cause, at any depth — a nested duplicate previously surfaced as - `Unknown tool`, sending readers after a missing registration. - """ - inner = FastMCP("Inner") - inner.add_provider(self._app(marker="A"), namespace="a") - inner.add_provider(self._app(marker="B"), namespace="b") - server = FastMCP("Top") - server.add_provider(inner, namespace="outer") - - result = await server.call_tool("outer_a_form", {}) - (ref,) = _tool_refs(result.structured_content) - - with pytest.raises(ToolError, match="composed more than once"): - await server.call_tool(ref, {"name": "alice"}) - - @pytest.mark.parametrize("backend_namespace", [None, "crm"]) - async def test_collapsed_catalog_over_a_proxy(self, backend_namespace): - """The collapsed-catalog fallback has to survive a backend that - renamed its app tools. Nothing named `save` was ever listed across - the wire, so the identity has to resolve against the remote listing - rather than against a name that only exists at the origin. - """ - app = self._app(marker="be") - backend = FastMCP("Backend") - backend.add_provider(app, namespace=backend_namespace) - - gateway = FastMCP("Gateway") - gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - gateway.add_transform(RegexSearchTransform()) - - entry = f"{backend_namespace}_form" if backend_namespace else "form" - result = await gateway.call_tool(entry, {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == hashed_backend_name("contacts", "save") - - clicked = await gateway.call_tool(ref, {"name": "alice"}) - assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_versions_of_one_tool_are_a_single_target(self): - """Versions are listed individually and share an identity, but they - also share a name that resolves to the highest version on its own. - Only distinct names mean distinct copies of an app. - """ - app = FastMCPApp("contacts") - for version, prefix in (("1.0.0", "v1"), ("2.0.0", "v2")): - - def save(name: str, _prefix: str = prefix) -> str: - return f"{_prefix} saved {name}" - - app.add_tool(Tool.from_function(save, name="save", version=version)) - - @app.ui() - def form() -> Column: - return Column( - children=[Button(label="Save", on_click=CallTool(tool="save"))] - ) - - server = FastMCP("Platform") - server.add_provider(app) - - result = await server.call_tool("form", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == "save" - - clicked = await server.call_tool(ref, {"name": "alice"}) - assert clicked.content[0].text == "v2 saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_distinct_apps_sharing_a_backend_name(self): - """Identity and name must agree in both directions. Two apps can each - expose `save`: the identities differ and each has one candidate, but - the shared name resolves to only one of them. - """ - server = FastMCP("Platform") - for app_name, entry, marker in ( - ("crm", "crm_ui", "CRM"), - ("billing", "billing_ui", "BILLING"), - ): - app = FastMCPApp(app_name) - - @app.tool() - def save(name: str, _marker: str = marker) -> str: - return f"[{_marker}] saved {name}" - - @app.ui(entry) - def form() -> Column: - return Column( - children=[Button(label="Save", on_click=CallTool(tool="save"))] - ) - - server.add_provider(app) - - result = await server.call_tool("billing_ui", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == hashed_backend_name("billing", "save") - - async def test_proxy_refuses_a_remote_that_duplicates_an_app(self): - """A remote mounting one app twice sends back two tools claiming one - identity, and the proxy must refuse on the same terms a local - composition would rather than returning whichever came first. - """ - backend = FastMCP("Backend") - backend.add_provider(self._app(marker="A"), namespace="a") - backend.add_provider(self._app(marker="B"), namespace="b") - - gateway = FastMCP("Gateway") - gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - - with pytest.raises(ToolError, match="composed more than once"): - await gateway.call_tool( - hashed_backend_name("contacts", "save"), {"name": "alice"} - ) - - async def test_middleware_owns_the_names_it_shadows(self): - """Binding describes the listing a client will see, so it has to run - the middleware chain. An injected tool sharing a backend's name owns - that name at call time, and would be invisible to a listing taken - beneath middleware. - """ - app = FastMCPApp("contacts") - - @app.tool() - def save(name: str) -> str: - return f"[APP] saved {name}" - - @app.ui() - def form() -> Column: - return Column( - children=[Button(label="Save", on_click=CallTool(tool="save"))] - ) - - def injected(name: str) -> str: - return f"[INJECTED] saved {name}" - - server = FastMCP("Platform") - server.add_provider(app) - server.add_middleware( - ToolInjectionMiddleware([Tool.from_function(injected, name="save")]) - ) - - result = await server.call_tool("form", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == hashed_backend_name("contacts", "save") - - clicked = await server.call_tool(ref, {"name": "alice"}) - assert clicked.content[0].text == "[APP] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_middleware_produced_results_are_rebound(self): - """Middleware can answer a call itself, and such a result never - reaches the core dispatch path — so rebinding belongs above the - chain, not inside it. - """ - app = FastMCPApp("contacts") - - @app.tool() - def save(name: str) -> str: - return f"saved {name}" - - @app.ui() - def form() -> Column: - return Column( - children=[Button(label="Save", on_click=CallTool(tool="save"))] - ) - - server = FastMCP("Platform") - server.add_provider(app) - - entry = await server.get_tool("form") - assert entry is not None - server.add_middleware( - ToolInjectionMiddleware([entry.model_copy(update={"name": "injected"})]) - ) - - result = await server.call_tool("injected", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == "save" - - clicked = await server.call_tool(ref, {"name": "alice"}) - assert clicked.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_a_transform_cannot_unwire_an_app_tool(self): - """A meta override that keeps the identity but drops app visibility - leaves a tool that can be named yet no longer answers to its - identity — which is the only address a collapsed catalog has. - """ - backend = FastMCP("Backend") - backend.add_provider(self._app(marker="be")) - backend.add_transform( - ToolTransform({"save": ToolTransformConfig(meta={"team": "crm"})}) - ) - - transformed = next(t for t in await backend.list_tools() if t.name == "save") - assert transformed.meta is not None - assert transformed.meta["ui"]["visibility"] == ["app"] - assert transformed.meta["team"] == "crm" - - gateway = FastMCP("Gateway") - gateway.add_provider(ProxyProvider(lambda: ProxyClient(backend))) - gateway.add_transform(RegexSearchTransform()) - - result = await gateway.call_tool("form", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == hashed_backend_name("contacts", "save") - - clicked = await gateway.call_tool(ref, {"name": "alice"}) - assert clicked.content[0].text == "[be] saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_duplicate_copies_are_not_collapsed_by_a_shared_name(self): - """Copies whose backends collide on a name are the worst case, not the - safe one: two components become indistinguishable. Counting names - alone would see a single unambiguous target and bind to it. - """ - server = FastMCP("Platform") - for entry, marker in (("form_a", "A"), ("form_b", "B")): - app = FastMCPApp("contacts") - - @app.tool() - def save(name: str, _marker: str = marker) -> str: - return f"[{_marker}] saved {name}" - - @app.ui(entry) - def form() -> Column: - return Column( - children=[Button(label="Save", on_click=CallTool(tool="save"))] - ) - - server.add_provider(app) - - listed = await server.list_tools() - assert [t.key for t in listed].count("tool:save@") == 2 - - result = await server.call_tool("form_b", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == hashed_backend_name("contacts", "save") - - async def test_unresolvable_identity_is_restored(self): - """An inner server binds to a name that means nothing further out, so - a reference this server cannot resolve is restored to its identity - rather than left — a stranded name has no route back, an identity does. - """ - app = FastMCPApp("contacts") - - @app.ui() - def form() -> Column: - return Column( - children=[Button(label="Go", on_click=CallTool(tool="not_registered"))] - ) - - server = FastMCP("Platform") - server.add_provider(app) - - result = await server.call_tool("form", {}) - (ref,) = _tool_refs(result.structured_content) - assert ref == hashed_backend_name("contacts", "not_registered") - - class TestDynamicToolAdd: async def test_tool_added_after_first_call_is_reachable(self): """Tools added to an already-mounted app after the first call @@ -664,9 +157,10 @@ class TestDynamicToolAdd: class TestCollision: - async def test_distinct_hashes_resolve_independently(self): - """Two apps sharing a name but with different tool names hash - differently, so each tool resolves to itself.""" + async def test_same_app_name_same_tool_name_first_wins(self): + """Two apps with the same name and same tool name: the hash is + identical, so get_tool_by_hash returns the first match. This is + the same first-match behavior the old get_app_tool had.""" app_a = FastMCPApp("shared") app_b = FastMCPApp("shared") @@ -678,67 +172,14 @@ class TestCollision: def save_b(name: str) -> str: return f"from B: {name}" + # Register under a different local tool name to avoid + # actual collision at the provider level. The hash collision + # only happens when both app name AND tool name match. + # This test just verifies one app's tool is reachable. server = FastMCP("Platform") server.add_provider(app_a) server.add_provider(app_b) - result = await server.call_tool( - hashed_backend_name("shared", "save"), {"name": "Eve"} - ) + hashed_name = hashed_backend_name("shared", "save") + result = await server.call_tool(hashed_name, {"name": "Eve"}) assert result.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - result_b = await server.call_tool( - hashed_backend_name("shared", "save_b"), {"name": "Eve"} - ) - assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_ambiguous_identity_raises_rather_than_guessing(self): - """The same app composed into two branches yields two tools with one - identity. Routing to either would silently execute the wrong branch's - tool, so the call is refused.""" - server = FastMCP("Platform") - for marker, namespace in (("A", "a"), ("B", "b")): - app = FastMCPApp("contacts") - - @app.tool() - def save(name: str, _marker: str = marker) -> str: - return f"from {_marker}: {name}" - - server.add_provider(app, namespace=namespace) - - with pytest.raises(ToolError, match="Ambiguous app tool"): - await server.call_tool( - hashed_backend_name("contacts", "save"), {"name": "Eve"} - ) - - async def test_distinct_app_names_route_independently_through_a_gateway(self): - """The multi-tenant gateway shape: distinct app names stay unambiguous - no matter how many backends sit behind one proxy.""" - - def backend(marker: str, app_name: str) -> FastMCP: - app = FastMCPApp(app_name) - - @app.tool() - def save(name: str) -> str: - return f"from {marker}: {name}" - - server = FastMCP(f"Backend-{marker}") - server.add_provider(app) - return server - - first = backend("A", "crm") - second = backend("B", "billing") - - gateway = FastMCP("Gateway") - gateway.add_provider(ProxyProvider(lambda: ProxyClient(first)), namespace="a") - gateway.add_provider(ProxyProvider(lambda: ProxyClient(second)), namespace="b") - - result_a = await gateway.call_tool( - hashed_backend_name("crm", "save"), {"name": "Eve"} - ) - assert result_a.content[0].text == "from A: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - result_b = await gateway.call_tool( - hashed_backend_name("billing", "save"), {"name": "Eve"} - ) - assert result_b.content[0].text == "from B: Eve" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] diff --git a/tests/server/providers/test_skills_provider.py b/tests/server/providers/test_skills_provider.py index cfc03e7d2..f57d4815c 100644 --- a/tests/server/providers/test_skills_provider.py +++ b/tests/server/providers/test_skills_provider.py @@ -12,6 +12,7 @@ from fastmcp.server.providers.skills import ( ClaudeSkillsProvider, SkillProvider, SkillsDirectoryProvider, + SkillsProvider, ) from fastmcp.server.providers.skills._common import parse_frontmatter from fastmcp.server.providers.skills.skill_provider import SkillFileResource @@ -91,26 +92,6 @@ This is my skill content. assert provider.skill_info.description == "A test skill" assert len(provider.skill_info.files) == 3 - def test_loads_frontmatter_from_utf8_bom_skill(self, tmp_path: Path): - skill_dir = tmp_path / "bom-skill" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text( - "\ufeff---\n" - "name: bom-skill\n" - "description: Skill saved with a UTF-8 BOM\n" - "---\n" - "# BOM Skill\n", - encoding="utf-8", - ) - - provider = SkillProvider(skill_path=skill_dir) - - assert provider.skill_info.description == "Skill saved with a UTF-8 BOM" - assert provider.skill_info.frontmatter == { - "name": "bom-skill", - "description": "Skill saved with a UTF-8 BOM", - } - def test_raises_if_directory_missing(self, tmp_path: Path): with pytest.raises(FileNotFoundError, match="Skill directory not found"): SkillProvider(skill_path=tmp_path / "nonexistent") @@ -175,26 +156,6 @@ This is my skill content. assert isinstance(result[0], TextResourceContents) assert "# My Skill" in result[0].text - async def test_read_main_file_with_literal_percent_in_name(self, tmp_path: Path): - """A custom main_file_name containing a literal '%' must round-trip - through the same encode/decode path as supporting files (#4545).""" - skill_dir = tmp_path / "percent-main-skill" - skill_dir.mkdir() - (skill_dir / "MAIN%20FILE.md").write_text("# Demo\n") - - mcp = FastMCP("Test") - mcp.add_provider( - SkillProvider(skill_path=skill_dir, main_file_name="MAIN%20FILE.md") - ) - - async with Client(mcp) as client: - resources = await client.list_resources() - main = next( - r for r in resources if r.name == "percent-main-skill/MAIN%20FILE.md" - ) - result = await client.read_resource(main.uri) - assert "# Demo" in result[0].text - async def test_read_manifest(self, single_skill_dir: Path): mcp = FastMCP("Test") mcp.add_provider(SkillProvider(skill_path=single_skill_dir)) @@ -249,76 +210,6 @@ This is my skill content. result = await client.read_resource(AnyUrl("skill://my-skill/reference.md")) assert "# Reference" in result[0].text - async def test_read_supporting_file_with_space_in_name(self, tmp_path: Path): - """Percent-encoded resource URIs for supporting files must round-trip (#4545).""" - skill_dir = tmp_path / "space-skill" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("# Skill\n") - (skill_dir / "setup guide.md").write_text("SPACE OK") - - mcp = FastMCP("Test") - mcp.add_provider( - SkillProvider(skill_path=skill_dir, supporting_files="resources") - ) - - async with Client(mcp) as client: - resources = await client.list_resources() - supporting = next( - r for r in resources if r.name == "space-skill/setup guide.md" - ) - assert str(supporting.uri) == "skill://space-skill/setup%20guide.md" - - result = await client.read_resource(supporting.uri) - assert result[0].text == "SPACE OK" - - async def test_read_supporting_file_with_utf8_name(self, tmp_path: Path): - skill_dir = tmp_path / "utf8-skill" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("# Skill\n") - (skill_dir / "café.md").write_text("UTF8 OK", encoding="utf-8") - - mcp = FastMCP("Test") - mcp.add_provider( - SkillProvider(skill_path=skill_dir, supporting_files="resources") - ) - - async with Client(mcp) as client: - resources = await client.list_resources() - supporting = next(r for r in resources if r.name == "utf8-skill/café.md") - - result = await client.read_resource(supporting.uri) - assert result[0].text == "UTF8 OK" - - async def test_percent_encoded_name_does_not_collide_with_space( - self, tmp_path: Path - ): - """A filename that already contains a literal '%20' must not be confused - with a space-containing filename once both are percent-encoded into - resource URIs (#4545).""" - skill_dir = tmp_path / "percent-skill" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("# Skill\n") - (skill_dir / "setup guide.md").write_text("SPACE OK") - (skill_dir / "setup%20guide.md").write_text("LITERAL PERCENT OK") - - mcp = FastMCP("Test") - mcp.add_provider( - SkillProvider(skill_path=skill_dir, supporting_files="resources") - ) - - async with Client(mcp) as client: - resources = await client.list_resources() - by_name = {r.name: r for r in resources} - space_uri = by_name["percent-skill/setup guide.md"].uri - literal_uri = by_name["percent-skill/setup%20guide.md"].uri - - assert str(space_uri) != str(literal_uri) - - space_result = await client.read_resource(space_uri) - literal_result = await client.read_resource(literal_uri) - assert space_result[0].text == "SPACE OK" - assert literal_result[0].text == "LITERAL PERCENT OK" - async def test_skill_resource_meta(self, single_skill_dir: Path): """SkillResource populates meta with skill name and is_manifest.""" provider = SkillProvider(skill_path=single_skill_dir) @@ -723,6 +614,13 @@ description: Second occurrence assert resources == [] +class TestSkillsProviderAlias: + """Test that SkillsProvider is a backwards-compatible alias.""" + + def test_skills_provider_is_alias(self): + assert SkillsProvider is SkillsDirectoryProvider + + class TestClaudeSkillsProvider: def test_default_root_is_claude_skills_dir(self, tmp_path: Path, monkeypatch): # Mock Path.home() to return a temp path (use tmp_path for cross-platform compatibility) diff --git a/docs/v3/apps/images/app-datatable.png b/tests/server/sampling/__init__.py similarity index 100% rename from docs/v3/apps/images/app-datatable.png rename to tests/server/sampling/__init__.py diff --git a/tests/server/sampling/test_prepare_tools.py b/tests/server/sampling/test_prepare_tools.py new file mode 100644 index 000000000..b0639b492 --- /dev/null +++ b/tests/server/sampling/test_prepare_tools.py @@ -0,0 +1,111 @@ +"""Tests for prepare_tools helper function.""" + +import pytest + +from fastmcp.server.sampling.run import prepare_tools +from fastmcp.server.sampling.sampling_tool import SamplingTool +from fastmcp.tools.function_tool import FunctionTool +from fastmcp.tools.tool_transform import ArgTransform, TransformedTool + + +class TestPrepareTools: + """Tests for prepare_tools().""" + + def test_prepare_tools_with_none(self): + """Test that None returns None.""" + result = prepare_tools(None) + assert result is None + + def test_prepare_tools_with_sampling_tool(self): + """Test that SamplingTool instances pass through.""" + + def search(query: str) -> str: + return f"Results: {query}" + + sampling_tool = SamplingTool.from_function(search) + result = prepare_tools([sampling_tool]) + + assert result is not None + assert len(result) == 1 + assert result[0] is sampling_tool + + def test_prepare_tools_with_function(self): + """Test that plain functions are converted.""" + + def search(query: str) -> str: + """Search function.""" + return f"Results: {query}" + + result = prepare_tools([search]) + + assert result is not None + assert len(result) == 1 + assert isinstance(result[0], SamplingTool) + assert result[0].name == "search" + + def test_prepare_tools_with_function_tool(self): + """Test that FunctionTool instances are converted.""" + + def search(query: str) -> str: + """Search the web.""" + return f"Results: {query}" + + function_tool = FunctionTool.from_function(search) + result = prepare_tools([function_tool]) + + assert result is not None + assert len(result) == 1 + assert isinstance(result[0], SamplingTool) + assert result[0].name == "search" + assert result[0].description == "Search the web." + + def test_prepare_tools_with_transformed_tool(self): + """Test that TransformedTool instances are converted.""" + + def original(query: str) -> str: + """Original tool.""" + return f"Results: {query}" + + function_tool = FunctionTool.from_function(original) + transformed_tool = TransformedTool.from_tool( + function_tool, + name="search_v2", + transform_args={"query": ArgTransform(name="q")}, + ) + + result = prepare_tools([transformed_tool]) + + assert result is not None + assert len(result) == 1 + assert isinstance(result[0], SamplingTool) + assert result[0].name == "search_v2" + assert "q" in result[0].parameters.get("properties", {}) + + def test_prepare_tools_with_mixed_types(self): + """Test that mixed tool types are all converted.""" + + def plain_fn(x: int) -> int: + return x * 2 + + def fn_for_tool(y: int) -> int: + return y * 3 + + function_tool = FunctionTool.from_function(fn_for_tool) + sampling_tool = SamplingTool.from_function(lambda z: z * 4, name="lambda_tool") + + result = prepare_tools([plain_fn, function_tool, sampling_tool]) + + assert result is not None + assert len(result) == 3 + assert all(isinstance(t, SamplingTool) for t in result) + + def test_prepare_tools_with_invalid_type(self): + """Test that invalid types raise TypeError.""" + + with pytest.raises(TypeError, match="Expected SamplingTool, FunctionTool"): + prepare_tools(["not a tool"]) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + def test_prepare_tools_empty_list(self): + """Test that empty list returns None.""" + result = prepare_tools([]) + assert result is None diff --git a/tests/server/sampling/test_sampling_tool.py b/tests/server/sampling/test_sampling_tool.py new file mode 100644 index 000000000..792161ea3 --- /dev/null +++ b/tests/server/sampling/test_sampling_tool.py @@ -0,0 +1,440 @@ +"""Tests for SamplingTool.""" + +import pytest +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser + +from fastmcp.exceptions import AuthorizationError +from fastmcp.server.auth import AccessToken, require_scopes +from fastmcp.server.context import _current_transport +from fastmcp.server.sampling import SamplingTool +from fastmcp.tools.function_tool import FunctionTool +from fastmcp.tools.tool_transform import ArgTransform, TransformedTool + + +class TestSamplingToolFromFunction: + """Tests for SamplingTool.from_function().""" + + def test_from_simple_function(self): + def search(query: str) -> str: + """Search the web.""" + return f"Results for: {query}" + + tool = SamplingTool.from_function(search) + + assert tool.name == "search" + assert tool.description == "Search the web." + assert "query" in tool.parameters.get("properties", {}) + assert tool.fn is search + + def test_from_function_with_overrides(self): + def search(query: str) -> str: + return f"Results for: {query}" + + tool = SamplingTool.from_function( + search, + name="web_search", + description="Search the internet", + ) + + assert tool.name == "web_search" + assert tool.description == "Search the internet" + + def test_from_lambda_requires_name(self): + with pytest.raises(ValueError, match="must provide a name for lambda"): + SamplingTool.from_function(lambda x: x) + + def test_from_lambda_with_name(self): + tool = SamplingTool.from_function(lambda x: x * 2, name="double") + + assert tool.name == "double" + + def test_from_async_function(self): + async def async_search(query: str) -> str: + """Async search.""" + return f"Async results for: {query}" + + tool = SamplingTool.from_function(async_search) + + assert tool.name == "async_search" + assert tool.description == "Async search." + + def test_multiple_parameters(self): + def search(query: str, limit: int = 10, include_images: bool = False) -> str: + """Search with options.""" + return f"Results for: {query}" + + tool = SamplingTool.from_function(search) + props = tool.parameters.get("properties", {}) + + assert "query" in props + assert "limit" in props + assert "include_images" in props + + +class TestSamplingToolRun: + """Tests for SamplingTool.run().""" + + async def test_run_sync_function(self): + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + tool = SamplingTool.from_function(add) + result = await tool.run({"a": 2, "b": 3}) + assert result == 5 + + async def test_run_async_function(self): + async def async_add(a: int, b: int) -> int: + """Add two numbers asynchronously.""" + return a + b + + tool = SamplingTool.from_function(async_add) + result = await tool.run({"a": 2, "b": 3}) + assert result == 5 + + async def test_run_with_no_arguments(self): + def get_value() -> str: + """Return a fixed value.""" + return "hello" + + tool = SamplingTool.from_function(get_value) + result = await tool.run() + assert result == "hello" + + async def test_run_with_none_arguments(self): + def get_value() -> str: + """Return a fixed value.""" + return "hello" + + tool = SamplingTool.from_function(get_value) + result = await tool.run(None) + assert result == "hello" + + +class TestSamplingToolSDKConversion: + """Tests for SamplingTool._to_sdk_tool() internal method.""" + + def test_to_sdk_tool(self): + def search(query: str) -> str: + """Search the web.""" + return f"Results for: {query}" + + tool = SamplingTool.from_function(search) + sdk_tool = tool._to_sdk_tool() + + assert sdk_tool.name == "search" + assert sdk_tool.description == "Search the web." + assert "query" in sdk_tool.input_schema.get("properties", {}) + + +class TestSamplingToolFromCallableTool: + """Tests for SamplingTool.from_callable_tool().""" + + def test_from_function_tool(self): + """Test converting a FunctionTool to SamplingTool.""" + + def search(query: str) -> str: + """Search the web.""" + return f"Results for: {query}" + + function_tool = FunctionTool.from_function(search) + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + assert sampling_tool.name == "search" + assert sampling_tool.description == "Search the web." + assert "query" in sampling_tool.parameters.get("properties", {}) + # fn is now a wrapper that calls tool.run() for proper result processing + assert callable(sampling_tool.fn) + + def test_from_function_tool_with_overrides(self): + """Test converting FunctionTool with name/description overrides.""" + + def search(query: str) -> str: + """Search the web.""" + return f"Results for: {query}" + + function_tool = FunctionTool.from_function(search) + sampling_tool = SamplingTool.from_callable_tool( + function_tool, + name="web_search", + description="Search the internet", + ) + + assert sampling_tool.name == "web_search" + assert sampling_tool.description == "Search the internet" + + def test_from_transformed_tool(self): + """Test converting a TransformedTool to SamplingTool.""" + + def original(query: str, limit: int) -> str: + """Original tool.""" + return f"Results for: {query} (limit: {limit})" + + function_tool = FunctionTool.from_function(original) + transformed_tool = TransformedTool.from_tool( + function_tool, + name="search_transformed", + transform_args={"query": ArgTransform(name="q")}, + ) + + sampling_tool = SamplingTool.from_callable_tool(transformed_tool) + + assert sampling_tool.name == "search_transformed" + assert sampling_tool.description == "Original tool." + # The transformed tool should have 'q' instead of 'query' + assert "q" in sampling_tool.parameters.get("properties", {}) + assert "limit" in sampling_tool.parameters.get("properties", {}) + + async def test_from_function_tool_execution(self): + """Test that converted FunctionTool executes correctly.""" + + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + function_tool = FunctionTool.from_function(add) + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + result = await sampling_tool.run({"a": 2, "b": 3}) + assert result == 5 + + async def test_from_transformed_tool_execution(self): + """Test that converted TransformedTool executes correctly.""" + + def multiply(x: int, y: int) -> int: + """Multiply two numbers.""" + return x * y + + function_tool = FunctionTool.from_function(multiply) + transformed_tool = TransformedTool.from_tool( + function_tool, + transform_args={"x": ArgTransform(name="a"), "y": ArgTransform(name="b")}, + ) + + sampling_tool = SamplingTool.from_callable_tool(transformed_tool) + + # Use the transformed parameter names + result = await sampling_tool.run({"a": 3, "b": 4}) + # Result should be unwrapped from ToolResult + assert result == 12 + + def test_from_invalid_tool_type(self): + """Test that from_callable_tool rejects non-tool objects.""" + + class NotATool: + pass + + with pytest.raises( + TypeError, + match="Expected FunctionTool or TransformedTool", + ): + SamplingTool.from_callable_tool(NotATool()) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + def test_from_plain_function_fails(self): + """Test that plain functions are rejected by from_callable_tool.""" + + def my_function(): + pass + + with pytest.raises(TypeError, match="Expected FunctionTool or TransformedTool"): + SamplingTool.from_callable_tool(my_function) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + async def test_from_function_tool_with_output_schema(self): + """Test that FunctionTool with output_schema is handled correctly.""" + + def search(query: str) -> dict: + """Search for something.""" + return {"results": ["item1", "item2"], "count": 2} + + # Create FunctionTool with x-fastmcp-wrap-result + function_tool = FunctionTool.from_function( + search, + output_schema={ + "type": "object", + "properties": { + "results": {"type": "array"}, + "count": {"type": "integer"}, + }, + "x-fastmcp-wrap-result": True, + }, + ) + + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + # Run the tool - should unwrap the {"result": {...}} wrapper + result = await sampling_tool.run({"query": "test"}) + + # Should get the unwrapped dict, not ToolResult + assert isinstance(result, dict) + assert result == {"results": ["item1", "item2"], "count": 2} + + async def test_from_function_tool_without_wrap_result(self): + """Test that FunctionTool without x-fastmcp-wrap-result is handled correctly.""" + + def get_data() -> dict: + """Get some data.""" + return {"status": "ok", "value": 42} + + # Create FunctionTool with output_schema but no wrap-result flag + function_tool = FunctionTool.from_function( + get_data, + output_schema={ + "type": "object", + "properties": { + "status": {"type": "string"}, + "value": {"type": "integer"}, + }, + }, + ) + + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + # Run the tool - should return structured_content directly + result = await sampling_tool.run({}) + + assert isinstance(result, dict) + assert result == {"status": "ok", "value": 42} + + +class TestSamplingToolAuthEnforcement: + """Tests that auth-protected tools enforce auth when used via sampling.""" + + async def test_auth_protected_tool_blocked_without_token(self): + """An auth-protected tool wrapped as SamplingTool must reject + calls when no valid token is present in a non-stdio transport.""" + + def secret_action() -> str: + """Do something privileged.""" + return "secret" + + function_tool = FunctionTool.from_function( + secret_action, + auth=require_scopes("admin"), + ) + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + transport_token = _current_transport.set("streamable-http") + try: + with pytest.raises(AuthorizationError, match="insufficient permissions"): + await sampling_tool.run({}) + finally: + _current_transport.reset(transport_token) + + async def test_auth_protected_tool_blocked_with_wrong_scopes(self): + """An auth-protected tool rejects calls when the token lacks + the required scopes.""" + + def secret_action() -> str: + """Do something privileged.""" + return "secret" + + function_tool = FunctionTool.from_function( + secret_action, + auth=require_scopes("admin"), + ) + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + token = AccessToken( + token="test", + client_id="c", + scopes=["read"], + expires_at=None, + claims={}, + ) + transport_token = _current_transport.set("streamable-http") + auth_token = auth_context_var.set(AuthenticatedUser(token)) + try: + with pytest.raises(AuthorizationError, match="insufficient permissions"): + await sampling_tool.run({}) + finally: + auth_context_var.reset(auth_token) + _current_transport.reset(transport_token) + + async def test_auth_protected_tool_allowed_with_correct_scopes(self): + """An auth-protected tool succeeds when the token has the + required scopes.""" + + def secret_action() -> str: + """Do something privileged.""" + return "secret" + + function_tool = FunctionTool.from_function( + secret_action, + auth=require_scopes("admin"), + ) + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + token = AccessToken( + token="test", + client_id="c", + scopes=["admin"], + expires_at=None, + claims={}, + ) + transport_token = _current_transport.set("streamable-http") + auth_token = auth_context_var.set(AuthenticatedUser(token)) + try: + result = await sampling_tool.run({}) + assert result == "secret" + finally: + auth_context_var.reset(auth_token) + _current_transport.reset(transport_token) + + async def test_auth_protected_tool_skipped_on_stdio(self): + """Auth checks are skipped for stdio transport, matching + server dispatcher behavior.""" + + def secret_action() -> str: + """Do something privileged.""" + return "secret" + + function_tool = FunctionTool.from_function( + secret_action, + auth=require_scopes("admin"), + ) + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + transport_token = _current_transport.set("stdio") + try: + result = await sampling_tool.run({}) + assert result == "secret" + finally: + _current_transport.reset(transport_token) + + async def test_tool_without_auth_runs_normally(self): + """Tools without auth still run without any auth context.""" + + def public_action() -> str: + """Do something public.""" + return "public" + + function_tool = FunctionTool.from_function(public_action) + sampling_tool = SamplingTool.from_callable_tool(function_tool) + + result = await sampling_tool.run({}) + assert result == "public" + + async def test_auth_protected_transformed_tool_blocked(self): + """Auth checks also apply to TransformedTools with auth.""" + + def secret_action(x: int) -> int: + """Privileged computation.""" + return x * 2 + + function_tool = FunctionTool.from_function( + secret_action, + auth=require_scopes("compute"), + ) + transformed_tool = TransformedTool.from_tool( + function_tool, + transform_args={"x": ArgTransform(name="value")}, + ) + sampling_tool = SamplingTool.from_callable_tool(transformed_tool) + + transport_token = _current_transport.set("streamable-http") + try: + with pytest.raises(AuthorizationError, match="insufficient permissions"): + await sampling_tool.run({"value": 5}) + finally: + _current_transport.reset(transport_token) diff --git a/tests/tasks/server/__init__.py b/tests/server/tasks/__init__.py similarity index 100% rename from tests/tasks/server/__init__.py rename to tests/server/tasks/__init__.py diff --git a/tests/server/tasks/conftest.py b/tests/server/tasks/conftest.py new file mode 100644 index 000000000..496053bfa --- /dev/null +++ b/tests/server/tasks/conftest.py @@ -0,0 +1 @@ +"""Configuration for server task tests.""" diff --git a/tests/tasks/server/test_concurrent_dependencies.py b/tests/server/tasks/test_concurrent_dependencies.py similarity index 56% rename from tests/tasks/server/test_concurrent_dependencies.py rename to tests/server/tasks/test_concurrent_dependencies.py index 4cc33e5dc..eb5af1bb1 100644 --- a/tests/tasks/server/test_concurrent_dependencies.py +++ b/tests/server/tasks/test_concurrent_dependencies.py @@ -9,39 +9,30 @@ Regression tests for: import asyncio from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.dependencies import Progress from fastmcp.server.context import Context from fastmcp.server.dependencies import ( - Progress, get_access_token, get_http_headers, ) -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - call_tool_without_optin, - running_task_server, - submit_task, - wait_for_task, -) async def test_concurrent_foreground_tools_with_context(): - """Multiple concurrent tool calls sharing the same Context() default + """Multiple concurrent tool calls sharing the same CurrentContext() default should not raise ValueError from ContextVar token resets (#3654).""" mcp = FastMCP("test") results: list[str] = [] - @mcp.tool + @mcp.tool() async def slow_tool(name: str, ctx: Context) -> str: - await asyncio.sleep(0.01) + await asyncio.sleep(0.05) results.append(name) return f"done:{name}" - outcomes = await asyncio.gather( - *[ - call_tool_without_optin(mcp, "slow_tool", {"name": f"task-{i}"}) - for i in range(4) - ] - ) + async with Client(mcp) as client: + tasks = [client.call_tool("slow_tool", {"name": f"task-{i}"}) for i in range(4)] + outcomes = await asyncio.gather(*tasks) assert len(outcomes) == 4 for outcome in outcomes: @@ -53,7 +44,7 @@ async def test_concurrent_foreground_tools_with_progress(): should not raise AssertionError from _impl being None (#3656).""" mcp = FastMCP("test") - @mcp.tool + @mcp.tool() async def variable_tool( name: str, delay: float, progress: Progress = Progress() ) -> str: @@ -65,14 +56,14 @@ async def test_concurrent_foreground_tools_with_progress(): await progress.increment() return f"done:{name}" - outcomes = await asyncio.gather( - *[ - call_tool_without_optin( - mcp, "variable_tool", {"name": f"t-{i}", "delay": 0.01 * (i + 1)} + async with Client(mcp) as client: + tasks = [ + client.call_tool( + "variable_tool", {"name": f"t-{i}", "delay": 0.01 * (i + 1)} ) for i in range(4) ] - ) + outcomes = await asyncio.gather(*tasks) assert len(outcomes) == 4 for outcome in outcomes: @@ -80,34 +71,31 @@ async def test_concurrent_foreground_tools_with_progress(): async def test_concurrent_background_tasks_with_context(): - """Multiple concurrent background tasks sharing Context() should + """Multiple concurrent background tasks sharing _CurrentContext() should not raise ValueError from ContextVar token resets (#3654).""" mcp = FastMCP("test") - mcp.add_extension(TasksExtension()) @mcp.tool(task=True) async def bg_tool(name: str, ctx: Context) -> str: - await asyncio.sleep(0.01) + await asyncio.sleep(0.05) return f"bg:{name}" - async with running_task_server(mcp): - created = [ - await submit_task(mcp, "bg_tool", {"name": f"bg-{i}"}) for i in range(4) + async with Client(mcp) as client: + task_handles = [ + await client.call_tool("bg_tool", {"name": f"bg-{i}"}, task=True) + for i in range(4) ] - finals = await asyncio.gather(*[wait_for_task(mcp, c.task_id) for c in created]) + results = await asyncio.gather(*[t.result() for t in task_handles]) - assert len(finals) == 4 - for final in finals: - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"]["result"].startswith("bg:") + assert len(results) == 4 + for result in results: + assert result.content[0].text.startswith("bg:") async def test_concurrent_background_tasks_with_progress(): """Multiple concurrent background tasks sharing Progress() should not raise AssertionError from _impl being None (#3656).""" mcp = FastMCP("test") - mcp.add_extension(TasksExtension()) @mcp.tool(task=True) async def bg_progress_tool( @@ -121,63 +109,63 @@ async def test_concurrent_background_tasks_with_progress(): await progress.increment() return f"bg:{name}" - async with running_task_server(mcp): - created = [ - await submit_task( - mcp, + async with Client(mcp) as client: + task_handles = [ + await client.call_tool( "bg_progress_tool", {"name": f"bg-{i}", "delay": 0.01 * (i + 1)}, + task=True, ) for i in range(4) ] - finals = await asyncio.gather(*[wait_for_task(mcp, c.task_id) for c in created]) + results = await asyncio.gather(*[t.result() for t in task_handles]) - assert len(finals) == 4 - for final in finals: - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"]["result"].startswith("bg:") + assert len(results) == 4 + for result in results: + assert result.content[0].text.startswith("bg:") async def test_dependency_aenter_returns_fresh_instances(): - """Dependency.__aenter__ returns independent per-invocation objects, - not the shared default.""" + """Verify that Dependency.__aenter__ returns independent per-invocation + objects, not the shared default.""" mcp = FastMCP("test") instances: list[Context] = [] - @mcp.tool + @mcp.tool() async def capture_context(ctx: Context) -> str: instances.append(ctx) return "ok" - await asyncio.gather( - call_tool_without_optin(mcp, "capture_context", {}), - call_tool_without_optin(mcp, "capture_context", {}), - ) + async with Client(mcp) as client: + await asyncio.gather( + client.call_tool("capture_context", {}), + client.call_tool("capture_context", {}), + ) assert len(instances) == 2 assert instances[0] is not instances[1] async def test_progress_aenter_returns_fresh_instances(): - """Progress.__aenter__ returns independent per-invocation objects, - not the shared default.""" + """Verify that Progress.__aenter__ returns independent per-invocation + objects, not the shared default.""" progress_instances: list[Progress] = [] mcp = FastMCP("test") - @mcp.tool + @mcp.tool() async def capture_progress(progress: Progress = Progress()) -> str: progress_instances.append(progress) await progress.set_total(1) await progress.increment() return "ok" - await asyncio.gather( - call_tool_without_optin(mcp, "capture_progress", {}), - call_tool_without_optin(mcp, "capture_progress", {}), - ) + async with Client(mcp) as client: + await asyncio.gather( + client.call_tool("capture_progress", {}), + client.call_tool("capture_progress", {}), + ) assert len(progress_instances) == 2 assert progress_instances[0] is not progress_instances[1] @@ -185,33 +173,29 @@ async def test_progress_aenter_returns_fresh_instances(): async def test_sync_context_functions_work_in_background_without_deps(): - """Sync helpers like get_http_headers() work in a background task even when - the tool declares no Context or CurrentRequest dependency. + """Sync functions like get_http_request() should work in background tasks + even when the tool declares no Context or CurrentRequest dependency. - This exercises the sync snapshot fallback path which must work with the - memory:// (fakeredis) backend. + This exercises the sync Redis fallback path (_get_task_snapshot_sync → + _load_snapshot_sync_redis) which must work with both memory:// (fakeredis) + and real Redis backends. """ mcp = FastMCP("test") - mcp.add_extension(TasksExtension()) @mcp.tool(task=True) async def bare_sync_access() -> dict[str, str]: headers = get_http_headers() return {"has_headers": str(bool(headers))} - async with running_task_server(mcp): - created = await submit_task(mcp, "bare_sync_access", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"has_headers": "False"} + async with Client(mcp) as client: + task = await client.call_tool("bare_sync_access", {}, task=True) + result = await task.result() + assert result.data == {"has_headers": "False"} async def test_sync_context_functions_work_in_background_with_context(): - """Sync helpers work via ContextVar when Context loads the snapshot.""" + """Sync functions work via ContextVar when _CurrentContext loads the snapshot.""" mcp = FastMCP("test") - mcp.add_extension(TasksExtension()) @mcp.tool(task=True) async def context_sync_access(ctx: Context) -> dict[str, str]: @@ -223,10 +207,7 @@ async def test_sync_context_functions_work_in_background_with_context(): "is_background": str(ctx.is_background_task), } - async with running_task_server(mcp): - created = await submit_task(mcp, "context_sync_access", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"]["is_background"] == "True" + async with Client(mcp) as client: + task = await client.call_tool("context_sync_access", {}, task=True) + result = await task.result() + assert result.data["is_background"] == "True" diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py new file mode 100644 index 000000000..0441bf8a0 --- /dev/null +++ b/tests/server/tasks/test_context_background_task.py @@ -0,0 +1,741 @@ +"""Tests for Context background task support (SEP-1686). + +Tests Context API surface (unit) and background task elicitation (integration). +Integration tests use Client(mcp) with the real memory:// Docket backend — +no mocking of Redis, Docket, or session internals. +""" + +import asyncio +import gc +import json +from contextlib import AsyncExitStack +from datetime import datetime, timezone +from typing import Any, cast +from unittest.mock import AsyncMock, patch + +import pytest +from mcp import ServerSession +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp_types import ( + ClientCapabilities, + CreateMessageResult, + Implementation, + InitializeRequestParams, + TextContent, +) +from pydantic import BaseModel + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.elicitation import ElicitResult +from fastmcp.dependencies import CurrentDocket +from fastmcp.server.auth import AccessToken +from fastmcp.server.context import Context +from fastmcp.server.dependencies import get_access_token +from fastmcp.server.elicitation import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, +) +from fastmcp.server.tasks.context import ( + TaskContextInfo, + TaskContextSnapshot, + _remember_snapshot, + _task_sessions, + get_task_scope, + get_task_session, + register_task_session, +) +from fastmcp.server.tasks.elicitation import handle_task_input +from fastmcp.server.tasks.keys import ( + task_redis_prefix, +) + +# ============================================================================= +# Unit tests: Context API surface (no Redis/Docket needed) +# ============================================================================= + + +class TestContextBackgroundTaskSupport: + """Tests for Context.is_background_task and related functionality.""" + + def test_context_not_background_task_by_default(self): + """Context should not be a background task by default.""" + mcp = FastMCP("test") + ctx = Context(mcp) + assert ctx.is_background_task is False + assert ctx.task_id is None + + def test_context_is_background_task_when_task_id_provided(self): + """Context should be a background task when task_id is provided.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task-123") + assert ctx.is_background_task is True + assert ctx.task_id == "test-task-123" + + def test_context_task_id_is_readonly(self): + """task_id should be a read-only property.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task-123") + with pytest.raises(AttributeError): + setattr(ctx, "task_id", "new-id") + + +async def test_task_session_is_released_after_client_disconnect(): + _task_sessions.clear() + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def work() -> str: + return "done" + + async with Client(mcp) as client: + task = await client.call_tool("work", task=True) + await task.result() + assert len(_task_sessions) == 1 + + assert _task_sessions == {} + + +async def test_live_task_session_is_released_on_connection_disconnect(): + _task_sessions.clear() + + class MockConnection: + def __init__(self) -> None: + self.state: dict[str, object] = {} + self.exit_stack = AsyncExitStack() + + class MockSession: + def __init__(self, connection: MockConnection) -> None: + self._connection = connection + + connection = MockConnection() + session = MockSession(connection) + async with connection.exit_stack: + register_task_session("session", cast(ServerSession, session)) + session_ref = _task_sessions["session"] + + assert session_ref() is session + assert _task_sessions == {} + + +async def test_connection_cleanup_does_not_remove_replacement_session(): + _task_sessions.clear() + + class MockConnection: + def __init__(self) -> None: + self.state: dict[str, object] = {} + self.exit_stack = AsyncExitStack() + + class MockSession: + def __init__(self, connection: MockConnection | None = None) -> None: + self._connection = connection + + connection = MockConnection() + old_session = MockSession(connection) + new_session = MockSession() + async with connection.exit_stack: + register_task_session("shared", cast(ServerSession, old_session)) + register_task_session("shared", cast(ServerSession, new_session)) + + assert get_task_session("shared") is new_session + _task_sessions.clear() + + +def test_replaced_task_session_is_not_removed_by_old_weakref(): + _task_sessions.clear() + + class MockSession: + pass + + old_session = MockSession() + new_session = MockSession() + register_task_session("shared", cast(ServerSession, old_session)) + old_ref = _task_sessions["shared"] + register_task_session("shared", cast(ServerSession, new_session)) + + del old_session + gc.collect() + + assert old_ref() is None + assert get_task_session("shared") is new_session + + +class TestContextSessionProperty: + """Tests for Context.session property in different modes.""" + + def test_session_raises_when_no_session_available(self): + """session should raise RuntimeError when no session is available.""" + mcp = FastMCP("test") + ctx = Context(mcp) # No session, not a background task + + with pytest.raises(RuntimeError, match="session is not available"): + _ = ctx.session + + def test_session_uses_stored_session_in_background_task(self): + """session should use _session in background task mode.""" + mcp = FastMCP("test") + + class MockSession: + _fastmcp_state_prefix = "test-session" + + mock_session = MockSession() + ctx = Context( + mcp, session=cast(ServerSession, mock_session), task_id="test-task-123" + ) + + assert ctx.session is mock_session + + def test_session_uses_stored_session_during_on_initialize(self): + """session should use _session during on_initialize (no request context).""" + mcp = FastMCP("test") + + class MockSession: + _fastmcp_state_prefix = "test-session" + + mock_session = MockSession() + ctx = Context(mcp, session=cast(ServerSession, mock_session)) + + assert ctx.session is mock_session + + +class TestContextBackgroundTaskLogging: + """Tests for per-session log gating in background task mode.""" + + def _make_task_context( + self, mcp: FastMCP, session_id: str + ) -> tuple[Context, AsyncMock]: + send_log_message = AsyncMock() + + class MockConnection: + def __init__(self, session_id: str) -> None: + self.session_id = session_id + + class MockSession: + def __init__(self, session_id: str) -> None: + self._connection = MockConnection(session_id) + self._fastmcp_state_prefix = session_id + self.send_log_message = send_log_message + + session = MockSession(session_id) + ctx = Context( + mcp, session=cast(ServerSession, session), task_id="test-task-123" + ) + return ctx, send_log_message + + async def test_background_task_honors_session_level(self): + """A background task has a session but no request context; the + per-session minimum registered via logging/setLevel must still gate + its logs, so sub-threshold messages are not sent to the client.""" + mcp = FastMCP("test") + session_id = "session-abc" + mcp._client_log_levels[session_id] = "error" + + ctx, send_log_message = self._make_task_context(mcp, session_id) + assert ctx.is_background_task is True + assert ctx.request_context is None + + await ctx.info("info msg") + send_log_message.assert_not_called() + + await ctx.error("error msg") + send_log_message.assert_called_once() + + async def test_background_task_without_session_level_sends_all(self): + """When no per-session level is registered, background-task logs fall + back to the server default (which allows everything by default).""" + mcp = FastMCP("test") + ctx, send_log_message = self._make_task_context(mcp, "session-xyz") + + await ctx.info("info msg") + send_log_message.assert_called_once() + + +class TestContextClientExtensionBackgroundTask: + """Tests for Context.client_supports_extension() in background task mode. + + A background task has a live snapshot session but no request context. The + client's advertised capabilities are preserved on the snapshot session's + ``client_params``, so extension detection must read from the session rather + than gating on ``request_context``. + """ + + def _make_task_context( + self, mcp: FastMCP, extensions: dict[str, dict[str, Any]] | None + ) -> Context: + capabilities = ClientCapabilities(extensions=extensions) + client_params = InitializeRequestParams( + protocol_version="2025-06-18", + capabilities=capabilities, + client_info=Implementation(name="test-client", version="1.0"), + ) + + class MockSession: + _fastmcp_state_prefix = "session-ext" + + def __init__(self) -> None: + self.client_params = client_params + + session = MockSession() + return Context( + mcp, session=cast(ServerSession, session), task_id="test-task-ext" + ) + + def test_background_task_detects_advertised_extension(self): + """The snapshot session preserves the client's initialize params, so an + advertised extension is detected even with no request context.""" + mcp = FastMCP("test") + ctx = self._make_task_context(mcp, {"ext-abc": {}}) + + assert ctx.is_background_task is True + assert ctx.request_context is None + assert ctx.client_supports_extension("ext-abc") is True + assert ctx.client_supports_extension("ext-missing") is False + + def test_background_task_no_extensions_returns_false(self): + """When the client advertised no extensions, detection returns False.""" + mcp = FastMCP("test") + ctx = self._make_task_context(mcp, None) + + assert ctx.client_supports_extension("ext-abc") is False + + def test_no_session_returns_false(self): + """With no session available at all (e.g. distributed worker), the + method degrades to False rather than raising.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task-ext") + + assert ctx.client_supports_extension("ext-abc") is False + + +class TestContextElicitBackgroundTask: + """Tests for Context.elicit() in background task mode.""" + + async def test_elicit_raises_when_background_task_but_no_docket(self): + """elicit() should raise when in background task mode but Docket unavailable.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task-123") + + class MockSession: + _fastmcp_state_prefix = "test-session" + + ctx._session = cast(ServerSession, MockSession()) + + with pytest.raises(RuntimeError, match="Docket"): + await ctx.elicit("Need input", str) + + +class TestElicitFailFast: + """Tests for elicit_for_task fail-fast on notification push failure.""" + + async def test_elicit_returns_cancel_when_notification_push_fails(self): + """elicit_for_task should return cancel immediately when push_notification fails. + + If the client can't receive the input_required notification, waiting + for a response that will never come would block for up to 1 hour. + Instead, we return cancel immediately (fail-fast). + + This test patches ONLY push_notification — all other components + (Docket, Redis, session) are real via the memory:// backend. + """ + mcp = FastMCP("failfast-test") + elicit_started = asyncio.Event() + captured: dict[str, object] = {} + + @mcp.tool(task=True) + async def failfast_tool(ctx: Context) -> str: + elicit_started.set() + result = await ctx.elicit("This notification will fail", str) + captured["result_type"] = type(result).__name__ + captured["is_cancelled"] = isinstance(result, CancelledElicitation) + return "done" + + # Patch push_notification BEFORE starting client so it's active + # when the tool runs in the Docket worker + with patch( + "fastmcp.server.tasks.notifications.push_notification", + side_effect=ConnectionError("Redis queue unavailable"), + ): + async with Client(mcp) as client: + task = await client.call_tool("failfast_tool", {}, task=True) + await asyncio.wait_for(elicit_started.wait(), timeout=5.0) + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "done" + + # The tool should have received CancelledElicitation (fail-fast) + assert captured["is_cancelled"] is True + assert captured["result_type"] == "CancelledElicitation" + + +class TestContextDocumentation: + """Tests to verify Context documentation and API surface.""" + + def test_is_background_task_has_docstring(self): + """is_background_task property should have documentation.""" + assert Context.is_background_task.__doc__ is not None + assert "background task" in Context.is_background_task.__doc__.lower() + + def test_task_id_has_docstring(self): + """task_id property should have documentation.""" + assert Context.task_id.fget.__doc__ is not None + assert "task ID" in Context.task_id.fget.__doc__ + + def test_session_has_docstring(self): + """session property should document background task support.""" + assert Context.session.fget.__doc__ is not None + assert "background task" in Context.session.fget.__doc__.lower() + + +# ============================================================================= +# Integration tests: Client(mcp) + memory:// Docket backend +# ============================================================================= + + +class TestBackgroundTaskIntegration: + """Integration tests for background task context using real Docket memory backend. + + These tests use Client(mcp) with the memory:// broker — no mocking. + The memory:// backend provides a fully functional in-memory Redis store + that Docket uses automatically when running tests. + """ + + async def test_report_progress_in_background_task(self): + """report_progress() should complete without error in a background task.""" + mcp = FastMCP("progress-test") + progress_reported = asyncio.Event() + + @mcp.tool(task=True) + async def progress_tool(ctx: Context) -> str: + await ctx.report_progress(0, 100, "Starting...") + await ctx.report_progress(50, 100, "Half done") + await ctx.report_progress(100, 100, "Complete") + progress_reported.set() + return "done" + + async with Client(mcp) as client: + task = await client.call_tool("progress_tool", {}, task=True) + await asyncio.wait_for(progress_reported.wait(), timeout=5.0) + await task.wait(timeout=5.0) + result = await task.result() + assert result.data == "done" + + async def test_context_wiring_in_background_task(self): + """Context should be properly wired with task_id and session_id.""" + mcp = FastMCP("wiring-test") + task_completed = asyncio.Event() + captured: dict[str, object] = {} + + @mcp.tool(task=True) + async def verify_wiring(ctx: Context) -> str: + captured["task_id"] = ctx.task_id + captured["session_id"] = ctx.session_id + captured["is_background"] = ctx.is_background_task + task_completed.set() + return "ok" + + async with Client(mcp) as client: + task = await client.call_tool("verify_wiring", {}, task=True) + await asyncio.wait_for(task_completed.wait(), timeout=5.0) + await task.wait(timeout=5.0) + result = await task.result() + assert result.data == "ok" + + assert captured["task_id"] is not None + assert captured["session_id"] is not None + assert captured["is_background"] is True + + async def test_origin_request_id_round_trips_through_background_task(self): + """E2E: origin_request_id captured at submit time is restored in worker. + + We validate this by comparing ctx.origin_request_id with the value + stored in Docket's Redis for this task. + """ + + mcp = FastMCP("origin-request-id-roundtrip") + + @mcp.tool(task=True) + async def check_origin_request_id(ctx: Context, docket=CurrentDocket()) -> str: + assert ctx.is_background_task is True + assert ctx.request_context is None + assert ctx.task_id is not None + + origin = ctx.origin_request_id + assert origin is not None + assert isinstance(origin, str) + assert origin != "" + + # Verify the snapshot in Redis contains the same value + task_scope = get_task_scope() + key = docket.key(f"{task_redis_prefix(task_scope)}:{ctx.task_id}:snapshot") + async with docket.redis() as redis: + raw = await redis.get(key) + + assert raw is not None + if isinstance(raw, bytes): + raw = raw.decode() + snapshot = json.loads(raw) + assert snapshot["origin_request_id"] == origin + return "ok" + + async with Client(mcp) as client: + task = await client.call_tool("check_origin_request_id", {}, task=True) + result = await task.result() + assert result.data == "ok" + + @pytest.mark.xfail( + reason="Background-task sampling has no back-channel under SDK v2: the " + "per-request ServerSession that would carry sampling/createMessage is " + "gone once the submitting request completes, so ctx.sample() from a " + "worker raises NoBackChannelError. Needs a relay like elicit() " + "(context.py TODO); tracked in sdk-feedback.", + strict=True, + ) + async def test_sample_uses_origin_request_id_in_background_task(self): + """E2E: ctx.sample() works in a task without an active request context.""" + mcp = FastMCP("sample-background-test") + captured: dict[str, object] = {} + + @mcp.tool(task=True) + async def ask_client(ctx: Context) -> str: + assert ctx.is_background_task is True + assert ctx.request_context is None + assert ctx.origin_request_id is not None + result = await ctx.sample("Say hello") + return result.text or "" + + def sampling_handler(messages, params, ctx): + captured["called"] = True + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="hello from background"), + model="test-model", + stop_reason="endTurn", + ) + + async with Client(mcp, sampling_handler=sampling_handler) as client: + task = await client.call_tool("ask_client", {}, task=True) + result = await task.result() + + assert result.data == "hello from background" + assert captured["called"] is True + + async def test_elicit_accept_flow(self): + """E2E: tool elicits input, client accepts via elicitation_handler.""" + mcp = FastMCP("elicit-accept-test") + + @mcp.tool(task=True) + async def ask_name(ctx: Context) -> str: + result = await ctx.elicit("What is your name?", str) + if isinstance(result, AcceptedElicitation): + return f"Hello, {result.data}!" + return "No name provided" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"value": "Bob"}) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("ask_name", {}, task=True) + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "Hello, Bob!" + + async def test_elicit_decline_flow(self): + """E2E: tool elicits input, client declines via elicitation_handler.""" + mcp = FastMCP("elicit-decline-test") + + @mcp.tool(task=True) + async def optional_input(ctx: Context) -> str: + result = await ctx.elicit("Want to provide a name?", str) + if isinstance(result, DeclinedElicitation): + return "User declined" + if isinstance(result, AcceptedElicitation): + return f"Got: {result.data}" + return "Cancelled" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="decline") + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("optional_input", {}, task=True) + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "User declined" + + async def test_elicit_with_pydantic_model(self): + """E2E: tool elicits structured Pydantic input via elicitation_handler.""" + + class UserInfo(BaseModel): + name: str + age: int + + mcp = FastMCP("elicit-pydantic-test") + + @mcp.tool(task=True) + async def get_user_info(ctx: Context) -> str: + result = await ctx.elicit("Provide user info", UserInfo) + if isinstance(result, AcceptedElicitation): + assert isinstance(result.data, UserInfo) + return f"{result.data.name} is {result.data.age}" + return "No info" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"name": "Alice", "age": 30}) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("get_user_info", {}, task=True) + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "Alice is 30" + + async def test_handle_task_input_rejects_when_not_waiting(self): + """handle_task_input returns False when no task is waiting for input.""" + mcp = FastMCP("reject-test") + + @mcp.tool(task=True) + async def simple_tool() -> str: + return "done" + + async with Client(mcp) as client: + task = await client.call_tool("simple_tool", {}, task=True) + await task.wait(timeout=5.0) + + # Task already completed — no elicitation waiting + success = await handle_task_input( + task_id=task.task_id, + task_scope="nonexistent-scope", + action="accept", + content={"value": "too late"}, + fastmcp=mcp, + ) + assert success is False + + +class TestAccessTokenInBackgroundTasks: + """Tests for access token availability in background tasks (#3095). + + Integration tests use Client(mcp) with the real memory:// Docket backend. + The token snapshot/restore round-trip flows through actual Redis (fakeredis). + + Note: async tests run in isolated asyncio tasks, so ContextVar changes + are automatically scoped — no cleanup required. + """ + + async def test_token_round_trips_through_background_task(self): + """E2E: token set at submit time is available inside the worker.""" + mcp = FastMCP("token-roundtrip") + + @mcp.tool(task=True) + async def check_token(ctx: Context) -> str: + token = get_access_token() + if token is None: + return "no-token" + return f"{token.token}|{token.client_id}" + + test_token = AccessToken( + token="roundtrip-jwt", + client_id="test-client", + scopes=["read"], + claims={"sub": "user-1"}, + ) + auth_context_var.set(AuthenticatedUser(test_token)) + + async with Client(mcp) as client: + task = await client.call_tool("check_token", {}, task=True) + result = await task.result() + assert result.data == "roundtrip-jwt|test-client" + + async def test_no_token_when_unauthenticated(self): + """E2E: background task gets no token when nothing was set.""" + mcp = FastMCP("no-auth") + + @mcp.tool(task=True) + async def check_token(ctx: Context) -> str: + token = get_access_token() + return "no-token" if token is None else token.token + + async with Client(mcp) as client: + task = await client.call_tool("check_token", {}, task=True) + result = await task.result() + assert result.data == "no-token" + + async def test_expired_token_returns_none(self): + """get_access_token() returns None when task token has expired.""" + expired = AccessToken( + token="expired-jwt", + client_id="test-client", + scopes=["read"], + expires_at=int(datetime.now(timezone.utc).timestamp()) - 3600, + ) + _remember_snapshot( + "test-task", + TaskContextSnapshot(access_token_json=expired.model_dump_json()), + ) + fake_ctx = TaskContextInfo(task_id="test-task", task_scope="s") + with patch( + "fastmcp.server.dependencies.get_task_context", return_value=fake_ctx + ): + assert get_access_token() is None + + async def test_valid_token_with_future_expiry(self): + """get_access_token() returns token when expiry is in the future.""" + valid = AccessToken( + token="valid-jwt", + client_id="test-client", + scopes=["read"], + expires_at=int(datetime.now(timezone.utc).timestamp()) + 3600, + ) + _remember_snapshot( + "test-task", + TaskContextSnapshot(access_token_json=valid.model_dump_json()), + ) + fake_ctx = TaskContextInfo(task_id="test-task", task_scope="s") + with patch( + "fastmcp.server.dependencies.get_task_context", return_value=fake_ctx + ): + result = get_access_token() + assert result is not None + assert result.token == "valid-jwt" + + async def test_token_without_expiry_always_valid(self): + """get_access_token() returns token when no expires_at is set.""" + no_expiry = AccessToken( + token="eternal-jwt", + client_id="test-client", + scopes=["read"], + ) + _remember_snapshot( + "test-task", + TaskContextSnapshot(access_token_json=no_expiry.model_dump_json()), + ) + fake_ctx = TaskContextInfo(task_id="test-task", task_scope="s") + with patch( + "fastmcp.server.dependencies.get_task_context", return_value=fake_ctx + ): + result = get_access_token() + assert result is not None + assert result.token == "eternal-jwt" + + +class TestLifespanContextInBackgroundTasks: + """Tests for lifespan_context availability in background tasks (#3095).""" + + def test_lifespan_context_falls_back_to_server_result(self): + """lifespan_context reads from server when request_context is None.""" + mcp = FastMCP("test") + mcp._lifespan_result = {"db": "mock-db-connection", "cache": "mock-cache"} + + ctx = Context(mcp, task_id="test-task") + assert ctx.request_context is None + assert ctx.lifespan_context == { + "db": "mock-db-connection", + "cache": "mock-cache", + } + + def test_lifespan_context_returns_empty_dict_when_no_lifespan(self): + """lifespan_context returns {} when no lifespan is configured.""" + mcp = FastMCP("test") + ctx = Context(mcp, task_id="test-task") + assert ctx.request_context is None + assert ctx.lifespan_context == {} diff --git a/tests/server/tasks/test_custom_subclass_tasks.py b/tests/server/tasks/test_custom_subclass_tasks.py new file mode 100644 index 000000000..2077e066c --- /dev/null +++ b/tests/server/tasks/test_custom_subclass_tasks.py @@ -0,0 +1,184 @@ +"""Tests for custom component subclasses with task support. + +Verifies that custom Tool, Resource, and Prompt subclasses can use +background task execution by setting task_config. +""" + +import asyncio +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.tasks import TaskConfig +from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.components import FastMCPComponent + + +class CustomTool(Tool): + """A custom tool subclass with task support.""" + + task_config: TaskConfig = TaskConfig(mode="optional") + parameters: dict[str, Any] = {"type": "object", "properties": {}} + + async def run(self, arguments: dict[str, Any]) -> ToolResult: + return ToolResult(content=f"Custom tool executed with {arguments}") + + +class CustomToolWithLogic(Tool): + """A custom tool with actual async work.""" + + task_config: TaskConfig = TaskConfig(mode="optional") + parameters: dict[str, Any] = { + "type": "object", + "properties": {"duration": {"type": "integer"}}, + } + + async def run(self, arguments: dict[str, Any]) -> ToolResult: + duration = arguments.get("duration", 0) + await asyncio.sleep(duration * 0.01) # Short sleep for testing + return ToolResult(content=f"Completed after {duration} units") + + +class CustomToolForbidden(Tool): + """A custom tool with task_config forbidden (default).""" + + parameters: dict[str, Any] = {"type": "object", "properties": {}} + + async def run(self, arguments: dict[str, Any]) -> ToolResult: + return ToolResult(content="Sync only") + + +@pytest.fixture +def custom_tool_server(): + """Create a server with custom tool subclasses.""" + mcp = FastMCP("custom-tool-server") + mcp.add_tool(CustomTool(name="custom_tool", description="A custom tool")) + mcp.add_tool( + CustomToolWithLogic(name="custom_logic", description="Custom tool with logic") + ) + mcp.add_tool( + CustomToolForbidden(name="custom_forbidden", description="No task support") + ) + return mcp + + +async def test_custom_tool_sync_execution(custom_tool_server): + """Custom tool executes synchronously when no task metadata.""" + async with Client(custom_tool_server) as client: + result = await client.call_tool("custom_tool", {}) + assert "Custom tool executed" in str(result) + + +async def test_custom_tool_background_execution(custom_tool_server): + """Custom tool executes as background task when task=True.""" + async with Client(custom_tool_server) as client: + task = await client.call_tool("custom_tool", {}, task=True) + + assert task is not None + assert not task.returned_immediately + assert task.task_id is not None + + # Wait for result + result = await task.result() + assert "Custom tool executed" in str(result) + + +async def test_custom_tool_with_arguments(custom_tool_server): + """Custom tool receives arguments correctly in background execution.""" + async with Client(custom_tool_server) as client: + task = await client.call_tool("custom_logic", {"duration": 1}, task=True) + + assert task is not None + result = await task.result() + assert "Completed after 1 units" in str(result) + + +async def test_custom_tool_forbidden_sync_only(custom_tool_server): + """Custom tool with forbidden mode executes sync only.""" + async with Client(custom_tool_server) as client: + # Sync execution works + result = await client.call_tool("custom_forbidden", {}) + assert "Sync only" in str(result) + + +async def test_custom_tool_forbidden_rejects_task(custom_tool_server): + """Custom tool with forbidden mode returns error for task request.""" + async with Client(custom_tool_server) as client: + task = await client.call_tool("custom_forbidden", {}, task=True) + + # Should return immediately with error + assert task.returned_immediately + + +async def test_custom_tool_registers_with_docket(): + """Verify custom tool's register_with_docket is called during server startup.""" + from unittest.mock import MagicMock + + tool = CustomTool(name="test", description="test") + mock_docket = MagicMock() + + tool.register_with_docket(mock_docket) + + # Should register self.run with docket using prefixed key + mock_docket.register.assert_called_once() + call_args = mock_docket.register.call_args + assert call_args[1]["names"] == ["tool:test@"] + + +async def test_custom_tool_forbidden_does_not_register(): + """Verify custom tool with forbidden mode doesn't register with docket.""" + tool = CustomToolForbidden(name="test", description="test") + mock_docket = MagicMock() + + tool.register_with_docket(mock_docket) + + # Should NOT register + mock_docket.register.assert_not_called() + + +# ============================================================================== +# Base FastMCPComponent Tests +# ============================================================================== + + +class TestFastMCPComponentDocketMethods: + """Tests for base FastMCPComponent docket integration.""" + + def test_default_task_config_is_forbidden(self): + """Base component defaults to task_config mode='forbidden'.""" + component = FastMCPComponent(name="test") + assert component.task_config.mode == "forbidden" + + def test_register_with_docket_is_noop(self): + """Base register_with_docket does nothing (subclasses override).""" + component = FastMCPComponent(name="test") + mock_docket = MagicMock() + + # Should not raise, just no-op + component.register_with_docket(mock_docket) + + # Should not have called any docket methods + mock_docket.register.assert_not_called() + + async def test_add_to_docket_raises_when_forbidden(self): + """Base add_to_docket raises RuntimeError when mode is 'forbidden'.""" + component = FastMCPComponent(name="test") + mock_docket = MagicMock() + + with pytest.raises(RuntimeError, match="task execution not supported"): + await component.add_to_docket(mock_docket) + + async def test_add_to_docket_raises_not_implemented_when_allowed(self): + """Base add_to_docket raises NotImplementedError when not forbidden.""" + component = FastMCPComponent( + name="test", task_config=TaskConfig(mode="optional") + ) + mock_docket = MagicMock() + + with pytest.raises( + NotImplementedError, match="does not implement add_to_docket" + ): + await component.add_to_docket(mock_docket) diff --git a/tests/server/tasks/test_notifications.py b/tests/server/tasks/test_notifications.py new file mode 100644 index 000000000..ca7abfe1a --- /dev/null +++ b/tests/server/tasks/test_notifications.py @@ -0,0 +1,130 @@ +"""Tests for distributed notification queue (SEP-1686). + +Integration tests verify that the notification queue works end-to-end +using Client(mcp) with the real memory:// Docket backend. +No mocking of Redis, sessions, or Docket internals. +""" + +import asyncio + +import mcp_types + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.elicitation import ElicitResult +from fastmcp.server.context import Context +from fastmcp.server.elicitation import AcceptedElicitation +from fastmcp.server.tasks.notifications import ( + get_subscriber_count, +) + + +class TestNotificationIntegration: + """Integration tests for the notification queue using real Docket memory backend. + + The elicitation flow validates the full notification pipeline: + 1. Tool calls ctx.elicit() -> stores request in Redis -> pushes notification + 2. Subscriber picks up notification -> sends MCP notification to client + 3. Subscriber relays elicitation/create to client -> handler responds + 4. Relay pushes response to Redis -> BLPOP wakes tool + """ + + async def test_notification_delivered_during_elicitation(self): + """Full E2E: notification queue delivers input_required metadata to client. + + SDK v2 does not carry `notifications/tasks/status` in any protocol + version's core notification tables, so it is delivered through the + client's task-status notification binding (routed to Task objects) rather + than the message_handler. We observe it via `on_status_change`, whose + GetTaskResult carries the notification's `_meta`. + """ + mcp = FastMCP("notification-test") + captured: list[mcp_types.GetTaskResult] = [] + + @mcp.tool(task=True) + async def elicit_tool(ctx: Context) -> str: + result = await ctx.elicit("Enter value", str) + if isinstance(result, AcceptedElicitation): + return f"got: {result.data}" + return "no value" + + async def elicitation_handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"value": "hello"}) + + async with Client( + mcp, + elicitation_handler=elicitation_handler, + ) as client: + task = await client.call_tool("elicit_tool", {}, task=True) + task.on_status_change(captured.append) + + await task.wait(timeout=10.0) + result = await task.result() + assert result.data == "got: hello" + + # Verify the input_required notification was delivered with metadata + notification: mcp_types.GetTaskResult | None = None + for candidate in reversed(captured): + candidate_meta = candidate.meta + related_task = ( + candidate_meta.get("io.modelcontextprotocol/related-task") + if isinstance(candidate_meta, dict) + else None + ) + if ( + isinstance(related_task, dict) + and related_task.get("status") == "input_required" + ): + notification = candidate + break + + assert notification is not None, "expected notifications/tasks/status" + task_meta = notification.meta + assert isinstance(task_meta, dict) + + related_task = task_meta.get("io.modelcontextprotocol/related-task") + assert isinstance(related_task, dict) + assert related_task.get("taskId") == task.task_id + assert related_task.get("status") == "input_required" + + elicitation = related_task.get("elicitation") + assert isinstance(elicitation, dict) + assert elicitation.get("message") == "Enter value" + assert isinstance(elicitation.get("requestId"), str) + assert isinstance(elicitation.get("requestedSchema"), dict) + + async def test_subscriber_started_and_cleaned_up(self): + """Subscriber starts during background task and stops when client disconnects.""" + mcp = FastMCP("subscriber-test") + tool_started = asyncio.Event() + tool_continue = asyncio.Event() + + @mcp.tool(task=True) + async def lifecycle_tool(ctx: Context) -> str: + tool_started.set() + await asyncio.wait_for(tool_continue.wait(), timeout=10.0) + return "done" + + count_before = get_subscriber_count() + + async with Client(mcp) as client: + task = await client.call_tool("lifecycle_tool", {}, task=True) + await asyncio.wait_for(tool_started.wait(), timeout=5.0) + + # While a background task is running, subscriber should be active + count_during = get_subscriber_count() + assert count_during > count_before + + # Let the tool complete + tool_continue.set() + await task.wait(timeout=5.0) + result = await task.result() + assert result.data == "done" + + # After client disconnects, subscriber should be cleaned up + # Allow brief time for async cleanup + for _ in range(20): + if get_subscriber_count() == count_before: + break + await asyncio.sleep(0.05) + assert get_subscriber_count() == count_before diff --git a/tests/server/tasks/test_progress_dependency.py b/tests/server/tasks/test_progress_dependency.py new file mode 100644 index 000000000..6cc35996a --- /dev/null +++ b/tests/server/tasks/test_progress_dependency.py @@ -0,0 +1,155 @@ +"""Tests for FastMCP Progress dependency.""" + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.dependencies import Progress + + +async def test_progress_in_immediate_execution(): + """Test Progress dependency when calling tool immediately with Docket enabled.""" + mcp = FastMCP("test") + + @mcp.tool() + async def test_tool(progress: Progress = Progress()) -> str: + await progress.set_total(10) + await progress.increment() + await progress.set_message("Testing") + return "done" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + from mcp_types import TextContent + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "done" + + +async def test_progress_in_background_task(): + """Test Progress dependency in background task execution.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def test_task(progress: Progress = Progress()) -> str: + await progress.set_total(5) + await progress.increment() + await progress.set_message("Step 1") + return "done" + + async with Client(mcp) as client: + task = await client.call_tool("test_task", {}, task=True) + result = await task.result() + from mcp_types import TextContent + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "done" + + +async def test_progress_tracks_multiple_increments(): + """Test that Progress correctly tracks multiple increment calls.""" + mcp = FastMCP("test") + + @mcp.tool() + async def count_to_ten(progress: Progress = Progress()) -> str: + await progress.set_total(10) + for i in range(10): + await progress.increment() + return "counted" + + async with Client(mcp) as client: + result = await client.call_tool("count_to_ten", {}) + from mcp_types import TextContent + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "counted" + + +async def test_progress_status_message_in_background_task(): + """Regression test: TaskStatusResponse must include statusMessage field.""" + import asyncio + + mcp = FastMCP("test") + step_started = asyncio.Event() + + @mcp.tool(task=True) + async def task_with_progress(progress: Progress = Progress()) -> str: + await progress.set_total(3) + await progress.set_message("Step 1 of 3") + await progress.increment() + step_started.set() + + # Give test time to poll status + await asyncio.sleep(0.2) + + await progress.set_message("Step 2 of 3") + await progress.increment() + await progress.set_message("Step 3 of 3") + await progress.increment() + return "done" + + async with Client(mcp) as client: + task = await client.call_tool("task_with_progress", {}, task=True) + + # Wait for first step to start + await step_started.wait() + + # Get status and verify progress message + status = await task.status() + + # Verify statusMessage field is accessible and contains progress info + # Should not raise AttributeError + msg = status.status_message + assert msg is None or msg.startswith("Step") + + # Wait for completion + result = await task.result() + from mcp_types import TextContent + + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "done" + + +async def test_inmemory_progress_state(): + """Test that in-memory progress stores and returns state correctly.""" + mcp = FastMCP("test") + + @mcp.tool() + async def test_tool(progress: Progress = Progress()) -> dict: + # Initial state + assert progress.current is None + assert progress.total == 1 + assert progress.message is None + + # Set total + await progress.set_total(10) + assert progress.total == 10 + + # Increment + await progress.increment() + assert progress.current == 1 + + # Increment again + await progress.increment(2) + assert progress.current == 3 + + # Set message + await progress.set_message("Testing") + assert progress.message == "Testing" + + return { + "current": progress.current, + "total": progress.total, + "message": progress.message, + } + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + from mcp_types import TextContent + + assert isinstance(result.content[0], TextContent) + # The tool returns a dict showing the final state + import json + + state = json.loads(result.content[0].text) + assert state["current"] == 3 + assert state["total"] == 10 + assert state["message"] == "Testing" diff --git a/tests/server/tasks/test_resource_task_meta_parameter.py b/tests/server/tasks/test_resource_task_meta_parameter.py new file mode 100644 index 000000000..86d36b3a4 --- /dev/null +++ b/tests/server/tasks/test_resource_task_meta_parameter.py @@ -0,0 +1,287 @@ +""" +Tests for the explicit task_meta parameter on FastMCP.read_resource(). + +These tests verify that the task_meta parameter provides explicit control +over sync vs task execution for resources and resource templates. +""" + +import pytest +from mcp.shared.exceptions import MCPError + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.resources.base import Resource +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.tasks.config import TaskMeta + + +class TestResourceTaskMetaParameter: + """Tests for task_meta parameter on FastMCP.read_resource().""" + + async def test_task_meta_none_returns_resource_result(self): + """With task_meta=None (default), read_resource returns ResourceResult.""" + server = FastMCP("test") + + @server.resource("data://test") + async def simple_resource() -> str: + return "hello world" + + result = await server.read_resource("data://test") + + assert result.contents[0].content == "hello world" + + async def test_task_meta_none_on_task_enabled_resource_still_returns_result(self): + """Even for task=True resources, task_meta=None returns ResourceResult.""" + server = FastMCP("test") + + @server.resource("data://test", task=True) + async def task_enabled_resource() -> str: + return "hello world" + + # Without task_meta, should execute synchronously + result = await server.read_resource("data://test") + + assert result.contents[0].content == "hello world" + + async def test_task_meta_on_forbidden_resource_raises_error(self): + """Providing task_meta to a task=False resource raises MCPError.""" + server = FastMCP("test") + + @server.resource("data://test", task=False) + async def sync_only_resource() -> str: + return "hello" + + with pytest.raises(MCPError) as exc_info: + await server.read_resource("data://test", task_meta=TaskMeta()) + + assert "does not support task-augmented execution" in str(exc_info.value) + + async def test_task_meta_fn_key_enrichment_for_resource(self): + """Verify that fn_key enrichment uses Resource.make_key().""" + resource_uri = "data://my-resource" + expected_key = Resource.make_key(resource_uri) + + assert expected_key == "resource:data://my-resource" + + async def test_task_meta_fn_key_enrichment_for_template(self): + """Verify that fn_key enrichment uses ResourceTemplate.make_key().""" + template_pattern = "data://{id}" + expected_key = ResourceTemplate.make_key(template_pattern) + + assert expected_key == "template:data://{id}" + + +class TestResourceTemplateTaslMeta: + """Tests for task_meta with resource templates.""" + + async def test_template_task_meta_none_returns_resource_result(self): + """With task_meta=None, template read returns ResourceResult.""" + server = FastMCP("test") + + @server.resource("item://{id}") + async def get_item(id: str) -> str: + return f"Item {id}" + + result = await server.read_resource("item://42") + + assert result.contents[0].content == "Item 42" + + async def test_template_task_meta_on_task_enabled_template_returns_result(self): + """Even for task=True templates, task_meta=None returns ResourceResult.""" + server = FastMCP("test") + + @server.resource("item://{id}", task=True) + async def get_item(id: str) -> str: + return f"Item {id}" + + # Without task_meta, should execute synchronously + result = await server.read_resource("item://42") + + assert result.contents[0].content == "Item 42" + + async def test_template_task_meta_on_forbidden_template_raises_error(self): + """Providing task_meta to a task=False template raises MCPError.""" + server = FastMCP("test") + + @server.resource("item://{id}", task=False) + async def sync_only_template(id: str) -> str: + return f"Item {id}" + + with pytest.raises(MCPError) as exc_info: + await server.read_resource("item://42", task_meta=TaskMeta()) + + assert "does not support task-augmented execution" in str(exc_info.value) + + +class TestResourceTaskMetaClientIntegration: + """Tests that task_meta works correctly with the Client for resources.""" + + async def test_client_read_resource_without_task_gets_immediate_result(self): + """Client without task=True gets immediate result.""" + server = FastMCP("test") + + @server.resource("data://test", task=True) + async def immediate_resource() -> str: + return "hello" + + async with Client(server) as client: + result = await client.read_resource("data://test") + + # Should get ReadResourceResult directly + assert "hello" in str(result) + + async def test_client_read_resource_with_task_creates_task(self): + """Client with task=True creates a background task.""" + server = FastMCP("test") + + @server.resource("data://test", task=True) + async def task_resource() -> str: + return "hello" + + async with Client(server) as client: + from fastmcp.client.tasks import ResourceTask + + task = await client.read_resource("data://test", task=True) + + assert isinstance(task, ResourceTask) + + # Wait for result + result = await task.result() + assert "hello" in str(result) + + async def test_client_read_template_with_task_creates_task(self): + """Client with task=True on template creates a background task.""" + server = FastMCP("test") + + @server.resource("item://{id}", task=True) + async def get_item(id: str) -> str: + return f"Item {id}" + + async with Client(server) as client: + from fastmcp.client.tasks import ResourceTask + + task = await client.read_resource("item://42", task=True) + + assert isinstance(task, ResourceTask) + + # Wait for result + result = await task.result() + assert "Item 42" in str(result) + + +class TestResourceTaskMetaDirectServerCall: + """Tests for direct server read_resource calls with task_meta.""" + + async def test_resource_can_read_another_resource_with_task(self): + """A resource can read another resource as a background task.""" + server = FastMCP("test") + + @server.resource("data://inner", task=True) + async def inner_resource() -> str: + return "inner data" + + @server.tool + async def outer_tool() -> str: + # Read inner resource as background task + result = await server.read_resource("data://inner", task_meta=TaskMeta()) + # Should get CreateTaskResult since we provided task_meta + return f"Created task: {result.task.task_id}" + + async with Client(server) as client: + result = await client.call_tool("outer_tool", {}) + assert "Created task:" in str(result) + + async def test_resource_can_read_another_resource_synchronously(self): + """A resource can read another resource synchronously (no task_meta).""" + server = FastMCP("test") + + @server.resource("data://inner", task=True) + async def inner_resource() -> str: + return "inner data" + + @server.tool + async def outer_tool() -> str: + # Read inner resource synchronously (no task_meta) + result = await server.read_resource("data://inner") + # Should get ResourceResult directly + return f"Got result: {result.contents[0].content}" + + async with Client(server) as client: + result = await client.call_tool("outer_tool", {}) + assert "Got result: inner data" in str(result) + + async def test_resource_can_read_template_with_task(self): + """A tool can read a resource template as a background task.""" + server = FastMCP("test") + + @server.resource("item://{id}", task=True) + async def get_item(id: str) -> str: + return f"Item {id}" + + @server.tool + async def outer_tool() -> str: + result = await server.read_resource("item://99", task_meta=TaskMeta()) + return f"Created task: {result.task.task_id}" + + async with Client(server) as client: + result = await client.call_tool("outer_tool", {}) + assert "Created task:" in str(result) + + async def test_resource_can_read_with_custom_ttl(self): + """A tool can read a resource as a background task with custom TTL.""" + server = FastMCP("test") + + @server.resource("data://inner", task=True) + async def inner_resource() -> str: + return "inner data" + + @server.tool + async def outer_tool() -> str: + custom_ttl = 45000 # 45 seconds + result = await server.read_resource( + "data://inner", task_meta=TaskMeta(ttl=custom_ttl) + ) + return f"Task TTL: {result.task.ttl}" + + async with Client(server) as client: + result = await client.call_tool("outer_tool", {}) + assert "Task TTL: 45000" in str(result) + + +class TestResourceTaskMetaTypeNarrowing: + """Tests for type narrowing based on task_meta parameter.""" + + async def test_read_resource_without_task_meta_type_is_resource_result(self): + """Calling read_resource without task_meta returns ResourceResult type.""" + server = FastMCP("test") + + @server.resource("data://test") + async def simple_resource() -> str: + return "hello" + + # This should type-check as ResourceResult, not the union type + result = await server.read_resource("data://test") + + # No isinstance check needed - type is narrowed by overload + content = result.contents[0].content + assert content == "hello" + + async def test_read_resource_with_task_meta_type_is_create_task_result(self): + """Calling read_resource with task_meta returns CreateTaskResult type.""" + server = FastMCP("test") + + @server.resource("data://test", task=True) + async def task_resource() -> str: + return "hello" + + async with Client(server) as client: + # Need to use client to get full task infrastructure + from fastmcp.client.tasks import ResourceTask + + task = await client.read_resource("data://test", task=True) + assert isinstance(task, ResourceTask) + + # For direct server call, we need the Client context for Docket + # This test verifies the overload works via client integration + result = await task.result() + assert "hello" in str(result) diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py new file mode 100644 index 000000000..acb30811e --- /dev/null +++ b/tests/server/tasks/test_server_tasks_parameter.py @@ -0,0 +1,441 @@ +""" +Tests for server `tasks` parameter default inheritance. + +Verifies that the server's `tasks` parameter correctly sets defaults for all +components (tools, prompts, resources), and that explicit component-level +settings properly override the server default. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client + + +@pytest.mark.timeout(10) +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_server_tasks_true_defaults_all_components(): + """Server with tasks=True makes all components default to supporting tasks.""" + mcp = FastMCP("test", tasks=True) + + @mcp.tool() + async def my_tool() -> str: + return "tool result" + + @mcp.prompt() + async def my_prompt() -> str: + return "prompt result" + + @mcp.resource("test://resource") + async def my_resource() -> str: + return "resource result" + + async with Client(mcp) as client: + # Verify all task-enabled components are registered with docket + # Components use prefixed keys: tool:name, prompt:name, resource:uri + docket = mcp.docket + assert docket is not None + assert "tool:my_tool@" in docket.tasks + assert "prompt:my_prompt@" in docket.tasks + assert "resource:test://resource@" in docket.tasks + + # Tool should support background execution + tool_task = await client.call_tool("my_tool", task=True) + assert not tool_task.returned_immediately + + # Prompt should support background execution + prompt_task = await client.get_prompt("my_prompt", task=True) + assert not prompt_task.returned_immediately + + # Resource should support background execution + resource_task = await client.read_resource("test://resource", task=True) + assert not resource_task.returned_immediately + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_server_tasks_false_defaults_all_components(): + """Server with tasks=False makes all components default to mode=forbidden.""" + import pytest + from mcp.shared.exceptions import MCPError + + mcp = FastMCP("test", tasks=False) + + @mcp.tool() + async def my_tool() -> str: + return "tool result" + + @mcp.prompt() + async def my_prompt() -> str: + return "prompt result" + + @mcp.resource("test://resource") + async def my_resource() -> str: + return "resource result" + + async with Client(mcp) as client: + # Tool with mode="forbidden" returns error when called with task=True + tool_task = await client.call_tool("my_tool", task=True, raise_on_error=False) + assert tool_task.returned_immediately + result = await tool_task.result() + assert result.is_error + assert "does not support task-augmented execution" in str(result) + + # Prompt with mode="forbidden" raises MCPError when called with task=True + with pytest.raises(MCPError): + await client.get_prompt("my_prompt", task=True) + + # Resource with mode="forbidden" raises MCPError when called with task=True + with pytest.raises(MCPError): + await client.read_resource("test://resource", task=True) + + +async def test_server_tasks_none_defaults_to_false(): + """Server with tasks=None (or omitted) defaults to False.""" + mcp = FastMCP("test") # tasks=None, defaults to False + + @mcp.tool() + async def my_tool() -> str: + return "tool result" + + async with Client(mcp) as client: + # Tool should NOT support background execution (mode="forbidden" from default) + tool_task = await client.call_tool("my_tool", task=True, raise_on_error=False) + assert tool_task.returned_immediately + result = await tool_task.result() + assert result.is_error + assert "does not support task-augmented execution" in str(result) + + +async def test_component_explicit_false_overrides_server_true(): + """Component with task=False overrides server default of tasks=True.""" + mcp = FastMCP("test", tasks=True) + + @mcp.tool(task=False) + async def no_task_tool() -> str: + return "immediate result" + + @mcp.tool() + async def default_tool() -> str: + return "background result" + + async with Client(mcp) as client: + # Verify docket registration matches task settings (prefixed keys) + docket = mcp.docket + assert docket is not None + assert ( + "tool:no_task_tool@" not in docket.tasks + ) # task=False means not registered + assert "tool:default_tool@" in docket.tasks # Inherits tasks=True + + # Explicit False (mode="forbidden") returns error when called with task=True + no_task = await client.call_tool( + "no_task_tool", task=True, raise_on_error=False + ) + assert no_task.returned_immediately + result = await no_task.result() + assert result.is_error + assert "does not support task-augmented execution" in str(result) + + # Default should support background execution + default_task = await client.call_tool("default_tool", task=True) + assert not default_task.returned_immediately + + +async def test_component_explicit_true_overrides_server_false(): + """Component with task=True overrides server default of tasks=False.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=True) + async def task_tool() -> str: + return "background result" + + @mcp.tool() + async def default_tool() -> str: + return "immediate result" + + async with Client(mcp) as client: + # Verify docket registration matches task settings (prefixed keys) + docket = mcp.docket + assert docket is not None + assert "tool:task_tool@" in docket.tasks # task=True means registered + assert "tool:default_tool@" not in docket.tasks # Inherits tasks=False + + # Explicit True should support background execution despite server default + task = await client.call_tool("task_tool", task=True) + assert not task.returned_immediately + + # Default (mode="forbidden") returns error when called with task=True + default = await client.call_tool( + "default_tool", task=True, raise_on_error=False + ) + assert default.returned_immediately + result = await default.result() + assert result.is_error + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_mixed_explicit_and_inherited(): + """Mix of explicit True/False/None on different components.""" + import pytest + from mcp.shared.exceptions import MCPError + + mcp = FastMCP("test", tasks=True) # Server default is True + + @mcp.tool() + async def inherited_tool() -> str: + return "inherits True" + + @mcp.tool(task=True) + async def explicit_true_tool() -> str: + return "explicit True" + + @mcp.tool(task=False) + async def explicit_false_tool() -> str: + return "explicit False" + + @mcp.prompt() + async def inherited_prompt() -> str: + return "inherits True" + + @mcp.prompt(task=False) + async def explicit_false_prompt() -> str: + return "explicit False" + + @mcp.resource("test://inherited") + async def inherited_resource() -> str: + return "inherits True" + + @mcp.resource("test://explicit_false", task=False) + async def explicit_false_resource() -> str: + return "explicit False" + + async with Client(mcp) as client: + # Verify docket registration matches task settings + # Components use prefixed keys: tool:name, prompt:name, resource:uri + docket = mcp.docket + assert docket is not None + # task=True (explicit or inherited) means registered (with prefixed keys) + assert "tool:inherited_tool@" in docket.tasks + assert "tool:explicit_true_tool@" in docket.tasks + assert "prompt:inherited_prompt@" in docket.tasks + assert "resource:test://inherited@" in docket.tasks + # task=False means NOT registered + assert "tool:explicit_false_tool@" not in docket.tasks + assert "prompt:explicit_false_prompt@" not in docket.tasks + assert "resource:test://explicit_false@" not in docket.tasks + + # Tools + inherited = await client.call_tool("inherited_tool", task=True) + assert not inherited.returned_immediately + + explicit_true = await client.call_tool("explicit_true_tool", task=True) + assert not explicit_true.returned_immediately + + # Explicit False (mode="forbidden") returns error + explicit_false = await client.call_tool( + "explicit_false_tool", task=True, raise_on_error=False + ) + assert explicit_false.returned_immediately + result = await explicit_false.result() + assert result.is_error + + # Prompts + inherited_prompt_task = await client.get_prompt("inherited_prompt", task=True) + assert not inherited_prompt_task.returned_immediately + + # Explicit False prompt (mode="forbidden") raises MCPError + with pytest.raises(MCPError): + await client.get_prompt("explicit_false_prompt", task=True) + + # Resources + inherited_resource_task = await client.read_resource( + "test://inherited", task=True + ) + assert not inherited_resource_task.returned_immediately + + # Explicit False resource (mode="forbidden") raises MCPError + with pytest.raises(MCPError): + await client.read_resource("test://explicit_false", task=True) + + +async def test_server_tasks_parameter_sets_component_defaults(): + """Server tasks parameter sets component defaults.""" + # Server tasks=True sets component defaults + mcp = FastMCP("test", tasks=True) + + @mcp.tool() + async def tool_inherits_true() -> str: + return "tool result" + + async with Client(mcp) as client: + # Tool inherits tasks=True from server + tool_task = await client.call_tool("tool_inherits_true", task=True) + assert not tool_task.returned_immediately + + # Server tasks=False sets component defaults + mcp2 = FastMCP("test2", tasks=False) + + @mcp2.tool() + async def tool_inherits_false() -> str: + return "tool result" + + async with Client(mcp2) as client: + # Tool inherits tasks=False (mode="forbidden") - returns error + tool_task = await client.call_tool( + "tool_inherits_false", task=True, raise_on_error=False + ) + assert tool_task.returned_immediately + result = await tool_task.result() + assert result.is_error + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_resource_template_inherits_server_tasks_default(): + """Resource templates inherit server tasks default.""" + mcp = FastMCP("test", tasks=True) + + @mcp.resource("test://{item_id}") + async def templated_resource(item_id: str) -> str: + return f"resource {item_id}" + + async with Client(mcp) as client: + # Template should support background execution + resource_task = await client.read_resource("test://123", task=True) + assert not resource_task.returned_immediately + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_multiple_components_same_name_different_tasks(): + """Different component types with same name can have different task settings.""" + import pytest + from mcp.shared.exceptions import MCPError + + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=True) + async def shared_name() -> str: + return "tool result" + + @mcp.prompt() + async def shared_name_prompt() -> str: + return "prompt result" + + async with Client(mcp) as client: + # Tool with explicit True should support background execution + tool_task = await client.call_tool("shared_name", task=True) + assert not tool_task.returned_immediately + + # Prompt inheriting False (mode="forbidden") raises MCPError + with pytest.raises(MCPError): + await client.get_prompt("shared_name_prompt", task=True) + + +async def test_task_with_custom_tool_name(): + """Tools with custom names work correctly as tasks (issue #2642). + + When a tool is registered with a custom name different from the function + name, task execution should use the custom name for Docket lookup. + """ + mcp = FastMCP("test", tasks=True) + + async def my_function() -> str: + return "result from custom-named tool" + + mcp.tool(my_function, name="custom-tool-name") + + async with Client(mcp) as client: + # Verify the tool is registered with its custom name in Docket (prefixed key) + docket = mcp.docket + assert docket is not None + assert "tool:custom-tool-name@" in docket.tasks + + # Call the tool as a task using its custom name + task = await client.call_tool("custom-tool-name", task=True) + assert not task.returned_immediately + result = await task + assert result.data == "result from custom-named tool" + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_task_with_custom_resource_name(): + """Resources with custom names work correctly as tasks. + + Resources are registered/looked up by their .key (URI), not their name. + """ + mcp = FastMCP("test", tasks=True) + + @mcp.resource("test://resource", name="custom-resource-name") + async def my_resource_func() -> str: + return "result from custom-named resource" + + async with Client(mcp) as client: + # Verify the resource is registered with its key (prefixed URI) in Docket + docket = mcp.docket + assert docket is not None + assert "resource:test://resource@" in docket.tasks + + # Call the resource as a task + task = await client.read_resource("test://resource", task=True) + assert not task.returned_immediately + result = await task.result() + assert result[0].text == "result from custom-named resource" + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_task_with_custom_template_name(): + """Resource templates with custom names work correctly as tasks. + + Templates are registered/looked up by their .key (uri_template), not their name. + """ + mcp = FastMCP("test", tasks=True) + + @mcp.resource("test://{item_id}", name="custom-template-name") + async def my_template_func(item_id: str) -> str: + return f"result for {item_id}" + + async with Client(mcp) as client: + # Verify the template is registered with its key (prefixed uri_template) in Docket + docket = mcp.docket + assert docket is not None + assert "template:test://{item_id}@" in docket.tasks + + # Call the template as a task + task = await client.read_resource("test://123", task=True) + assert not task.returned_immediately + result = await task.result() + assert result[0].text == "result for 123" diff --git a/tests/server/tasks/test_snapshot_restore.py b/tests/server/tasks/test_snapshot_restore.py new file mode 100644 index 000000000..946e1bf1b --- /dev/null +++ b/tests/server/tasks/test_snapshot_restore.py @@ -0,0 +1,108 @@ +"""Tests for ``restore_task_snapshot`` — the worker-level Docket dependency +that restores the task-context snapshot into the ``_task_snapshot`` +ContextVar before each task runs. + +With the snapshot restored up front, sync helpers (``get_access_token``, +``get_http_request``, etc.) never need to hit Redis themselves. These +tests exercise the restore path end-to-end (via in-memory Docket) and +the edge cases around non-fastmcp keys and failed restores. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.auth import AccessToken +from fastmcp.server.dependencies import get_access_token +from fastmcp.server.tasks.context import ( + TaskContextSnapshot, + _recall_snapshot, + get_task_context, + restore_task_snapshot, +) + + +async def test_snapshot_restored_before_user_code_runs(): + """A tool with no declared deps finds the snapshot already cached.""" + mcp = FastMCP("snapshot-restore-test") + seen_cached: list[bool] = [] + + @mcp.tool(task=True) + async def bare_tool() -> str: + info = get_task_context() + assert info is not None + seen_cached.append(_recall_snapshot(info.task_id) is not None) + return "ok" + + async with Client(mcp) as client: + task = await client.call_tool("bare_tool", {}, task=True) + await task.result() + + assert seen_cached == [True] + + +async def test_get_access_token_in_bg_task_without_context_dep(): + """Issue #3897 repro: get_access_token() works in a bg task that does + not declare Context as a dependency.""" + mcp = FastMCP("access-token-test") + seen_tokens: list[str | None] = [] + + @mcp.tool(task=True) + async def bare_tool() -> str: + token = get_access_token() + seen_tokens.append(token.token if token else None) + return "ok" + + test_token = AccessToken( + token="jwt-3897", + client_id="test-client", + scopes=["read"], + claims={"sub": "user-x"}, + ) + auth_context_var.set(AuthenticatedUser(test_token)) + + async with Client(mcp) as client: + task = await client.call_tool("bare_tool", {}, task=True) + await task.result() + + assert seen_tokens == ["jwt-3897"] + + +async def test_restore_failure_is_nonfatal(): + """If deserialization blows up, the task still runs to completion and + the snapshot cache stays empty.""" + mcp = FastMCP("restore-failure-test") + seen_cached: list[bool] = [] + + @mcp.tool(task=True) + async def bare_tool() -> str: + info = get_task_context() + assert info is not None + seen_cached.append(_recall_snapshot(info.task_id) is not None) + return "ok" + + def boom(*_args, **_kwargs): + raise RuntimeError("simulated deserialization failure") + + async with Client(mcp) as client: + with patch.object(TaskContextSnapshot, "from_json", boom): + task = await client.call_tool("bare_tool", {}, task=True) + result = await task.result() + + assert result.data == "ok" + assert seen_cached == [False] + + +async def test_restore_skipped_for_non_fastmcp_task_keys(): + """The restore dep returns cleanly for keys it doesn't recognize and + writes nothing to the snapshot cache.""" + # Direct calls bypass the worker, so Redis/Docket never gets involved + # — any attempt to touch them would raise. + await restore_task_snapshot(key="not-a-fastmcp-key") + await restore_task_snapshot(key="weird:client-a:task-1:tool:my_tool") + await restore_task_snapshot(key="") diff --git a/tests/server/tasks/test_sync_function_task_disabled.py b/tests/server/tasks/test_sync_function_task_disabled.py new file mode 100644 index 000000000..c5255b0b4 --- /dev/null +++ b/tests/server/tasks/test_sync_function_task_disabled.py @@ -0,0 +1,229 @@ +""" +Tests that synchronous functions cannot be used as background tasks. + +Docket requires async functions for background execution. FastMCP raises +ValueError when task=True is used with a sync function. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.prompts.function_prompt import FunctionPrompt +from fastmcp.resources.function_resource import FunctionResource +from fastmcp.tools.function_tool import FunctionTool + + +async def test_sync_tool_with_explicit_task_true_raises(): + """Sync tool with task=True raises ValueError.""" + mcp = FastMCP("test") + + with pytest.raises( + ValueError, match="uses a sync function but has task execution enabled" + ): + + @mcp.tool(task=True) + def sync_tool(x: int) -> int: + """A synchronous tool.""" + return x * 2 + + +async def test_sync_tool_with_inherited_task_true_raises(): + """Sync tool inheriting task=True from server raises ValueError.""" + mcp = FastMCP("test", tasks=True) + + with pytest.raises( + ValueError, match="uses a sync function but has task execution enabled" + ): + + @mcp.tool() # Inherits task=True from server + def sync_tool(x: int) -> int: + """A synchronous tool.""" + return x * 2 + + +async def test_sync_prompt_with_explicit_task_true_raises(): + """Sync prompt with task=True raises ValueError.""" + mcp = FastMCP("test") + + with pytest.raises( + ValueError, match="uses a sync function but has task execution enabled" + ): + + @mcp.prompt(task=True) + def sync_prompt() -> str: + """A synchronous prompt.""" + return "Hello" + + +async def test_sync_prompt_with_inherited_task_true_raises(): + """Sync prompt inheriting task=True from server raises ValueError.""" + mcp = FastMCP("test", tasks=True) + + with pytest.raises( + ValueError, match="uses a sync function but has task execution enabled" + ): + + @mcp.prompt() # Inherits task=True from server + def sync_prompt() -> str: + """A synchronous prompt.""" + return "Hello" + + +async def test_sync_resource_with_explicit_task_true_raises(): + """Sync resource with task=True raises ValueError.""" + mcp = FastMCP("test") + + with pytest.raises( + ValueError, match="uses a sync function but has task execution enabled" + ): + + @mcp.resource("test://sync", task=True) + def sync_resource() -> str: + """A synchronous resource.""" + return "data" + + +async def test_sync_resource_with_inherited_task_true_raises(): + """Sync resource inheriting task=True from server raises ValueError.""" + mcp = FastMCP("test", tasks=True) + + with pytest.raises( + ValueError, match="uses a sync function but has task execution enabled" + ): + + @mcp.resource("test://sync") # Inherits task=True from server + def sync_resource() -> str: + """A synchronous resource.""" + return "data" + + +async def test_async_tool_with_task_true_remains_enabled(): + """Async tools with task=True keep task support enabled.""" + mcp = FastMCP("test") + + @mcp.tool(task=True) + async def async_tool(x: int) -> int: + """An async tool.""" + return x * 2 + + # Tool should have task mode="optional" and be a FunctionTool + tool = await mcp.get_tool("async_tool") + assert isinstance(tool, FunctionTool) + assert tool.task_config.mode == "optional" + + +async def test_async_prompt_with_task_true_remains_enabled(): + """Async prompts with task=True keep task support enabled.""" + mcp = FastMCP("test") + + @mcp.prompt(task=True) + async def async_prompt() -> str: + """An async prompt.""" + return "Hello" + + # Prompt should have task mode="optional" and be a FunctionPrompt + prompt = await mcp.get_prompt("async_prompt") + assert isinstance(prompt, FunctionPrompt) + assert prompt.task_config.mode == "optional" + + +async def test_async_resource_with_task_true_remains_enabled(): + """Async resources with task=True keep task support enabled.""" + mcp = FastMCP("test") + + @mcp.resource("test://async", task=True) + async def async_resource() -> str: + """An async resource.""" + return "data" + + # Resource should have task mode="optional" and be a FunctionResource + resource = await mcp.get_resource("test://async") + assert isinstance(resource, FunctionResource) + assert resource.task_config.mode == "optional" + + +async def test_sync_tool_with_task_false_works(): + """Sync tool with explicit task=False works (no error).""" + mcp = FastMCP("test", tasks=True) + + @mcp.tool(task=False) # Explicitly disable + def sync_tool(x: int) -> int: + """A synchronous tool.""" + return x * 2 + + tool = await mcp.get_tool("sync_tool") + assert isinstance(tool, FunctionTool) + assert tool.task_config.mode == "forbidden" + + +async def test_sync_prompt_with_task_false_works(): + """Sync prompt with explicit task=False works (no error).""" + mcp = FastMCP("test", tasks=True) + + @mcp.prompt(task=False) # Explicitly disable + def sync_prompt() -> str: + """A synchronous prompt.""" + return "Hello" + + prompt = await mcp.get_prompt("sync_prompt") + assert isinstance(prompt, FunctionPrompt) + assert prompt.task_config.mode == "forbidden" + + +async def test_sync_resource_with_task_false_works(): + """Sync resource with explicit task=False works (no error).""" + mcp = FastMCP("test", tasks=True) + + @mcp.resource("test://sync", task=False) # Explicitly disable + def sync_resource() -> str: + """A synchronous resource.""" + return "data" + + resource = await mcp.get_resource("test://sync") + assert isinstance(resource, FunctionResource) + assert resource.task_config.mode == "forbidden" + + +# ============================================================================= +# Callable classes and staticmethods with async __call__ +# ============================================================================= + + +async def test_async_callable_class_tool_with_task_true_works(): + """Callable class with async __call__ and task=True should work.""" + from fastmcp.tools import Tool + + class AsyncCallableTool: + async def __call__(self, x: int) -> int: + return x * 2 + + # Callable classes use Tool.from_function() directly + tool = Tool.from_function(AsyncCallableTool(), task=True) + assert tool.task_config.mode == "optional" + + +async def test_async_callable_class_prompt_with_task_true_works(): + """Callable class with async __call__ and task=True should work.""" + from fastmcp.prompts import Prompt + + class AsyncCallablePrompt: + async def __call__(self) -> str: + return "Hello" + + # Callable classes use Prompt.from_function() directly + prompt = Prompt.from_function(AsyncCallablePrompt(), task=True) + assert prompt.task_config.mode == "optional" + + +async def test_sync_callable_class_tool_with_task_true_raises(): + """Callable class with sync __call__ and task=True should raise.""" + from fastmcp.tools import Tool + + class SyncCallableTool: + def __call__(self, x: int) -> int: + return x * 2 + + with pytest.raises( + ValueError, match="uses a sync function but has task execution enabled" + ): + Tool.from_function(SyncCallableTool(), task=True) diff --git a/tests/server/tasks/test_task_capabilities.py b/tests/server/tasks/test_task_capabilities.py new file mode 100644 index 000000000..3999c5dbe --- /dev/null +++ b/tests/server/tasks/test_task_capabilities.py @@ -0,0 +1,91 @@ +""" +Tests for SEP-1686 task capabilities declaration. + +Verifies that the server correctly advertises task support. +Task protocol is now always enabled. +""" + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.tasks import get_task_capabilities + + +async def test_capabilities_include_tasks(): + """Server capabilities always include tasks in first-class field (SEP-1686).""" + mcp = FastMCP("capability-test") + + @mcp.tool() + async def test_tool() -> str: + return "test" + + async with Client(mcp) as client: + # Get server initialization result which includes capabilities + init_result = client.initialize_result + + # Verify tasks capability is present as a first-class field (not experimental) + assert init_result.capabilities.tasks is not None + assert init_result.capabilities.tasks == get_task_capabilities() + # Verify it's NOT in experimental + assert "tasks" not in (init_result.capabilities.experimental or {}) + + +def test_only_tools_advertise_task_support(): + """Task requests advertise tools only, not prompts/resources (sdk-feedback #3). + + SDK v2 b1 ``ReadResourceRequestParams`` / ``GetPromptRequestParams`` have no + ``task`` field, so resource/prompt task submissions always graceful-degrade + to synchronous execution. Advertising those capabilities would mislead + clients into sending task-augmented reads/gets, so the honest contract is + tools-only. + """ + capabilities = get_task_capabilities() + assert capabilities is not None + requests = capabilities.requests + assert requests is not None + assert requests.tools is not None + assert requests.tools.call is not None + # No prompt/resource task capability of any form is advertised. + assert getattr(requests, "prompts", None) is None + assert getattr(requests, "resources", None) is None + dumped = requests.model_dump(exclude_none=True) + assert set(dumped) == {"tools"} + + +async def test_client_uses_task_capable_session(): + """Client uses task-capable initialization.""" + mcp = FastMCP("client-cap-test") + + @mcp.tool() + async def test_tool() -> str: + return "test" + + async with Client(mcp) as client: + # Client should have connected successfully with task capabilities + assert client.initialize_result is not None + # Session should be a ClientSession (task-capable init uses standard session) + assert type(client.session).__name__ == "ClientSession" + + +def test_capabilities_hidden_when_pydocket_too_old(monkeypatch): + """Capability advertisement and handler registration must agree. + + If ``is_docket_available()`` returns False (e.g. an old transitive + pydocket), the server skips registering task handlers — so it must + also stop advertising task capabilities, or clients would discover + task support and then hit "method not found" at runtime. + """ + import importlib.metadata + + from fastmcp.server import dependencies + + original_version = importlib.metadata.version + + def fake_version(name: str) -> str: + if name == "pydocket": + return "0.16.6" + return original_version(name) + + monkeypatch.setattr(dependencies, "_DOCKET_AVAILABLE", None) + monkeypatch.setattr(importlib.metadata, "version", fake_version) + + assert get_task_capabilities() is None diff --git a/tests/server/tasks/test_task_config.py b/tests/server/tasks/test_task_config.py new file mode 100644 index 000000000..d6e095509 --- /dev/null +++ b/tests/server/tasks/test_task_config.py @@ -0,0 +1,407 @@ +"""Tests for TaskConfig (SEP-1686). + +Tests for TaskConfig: +- Mode enforcement (forbidden, optional, required) +- Poll interval configuration +""" + +from datetime import timedelta + +import pytest +from mcp.shared.exceptions import MCPError +from mcp_types import TextContent, ToolExecution +from mcp_types import Tool as MCPTool + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.exceptions import ToolError +from fastmcp.server.tasks import TaskConfig +from fastmcp.tools.base import Tool + + +class TestTaskConfigNormalization: + """Test that boolean task values normalize correctly to TaskConfig.""" + + async def test_task_true_normalizes_to_optional(self): + """task=True should normalize to TaskConfig(mode='optional').""" + mcp = FastMCP("test", tasks=False) # Disable default task support + + @mcp.tool(task=True) + async def my_tool() -> str: + return "ok" + + tool = await mcp.get_tool("my_tool") + assert isinstance(tool, Tool) + assert tool.task_config.mode == "optional" + + async def test_task_false_normalizes_to_forbidden(self): + """task=False should normalize to TaskConfig(mode='forbidden').""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=False) + async def my_tool() -> str: + return "ok" + + tool = await mcp.get_tool("my_tool") + assert isinstance(tool, Tool) + assert tool.task_config.mode == "forbidden" + + async def test_task_config_passed_directly(self): + """TaskConfig should be preserved when passed directly.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=TaskConfig(mode="required")) + async def my_tool() -> str: + return "ok" + + tool = await mcp.get_tool("my_tool") + assert isinstance(tool, Tool) + assert tool.task_config.mode == "required" + + async def test_default_task_inherits_server_default(self): + """Default task value should inherit from server default.""" + # Server with tasks disabled + mcp_no_tasks = FastMCP("test", tasks=False) + + @mcp_no_tasks.tool() + def my_tool_sync() -> str: + return "ok" + + tool = await mcp_no_tasks.get_tool("my_tool_sync") + assert isinstance(tool, Tool) + assert tool.task_config.mode == "forbidden" + + # Server with tasks enabled + mcp_tasks = FastMCP("test", tasks=True) + + @mcp_tasks.tool() + async def my_tool_async() -> str: + return "ok" + + tool2 = await mcp_tasks.get_tool("my_tool_async") + assert isinstance(tool2, Tool) + assert tool2.task_config.mode == "optional" + + +class TestToolModeEnforcement: + """Test mode enforcement for tools.""" + + @pytest.fixture + def server(self): + """Create server with tools in different modes.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=TaskConfig(mode="required")) + async def required_tool() -> str: + """Tool that requires task execution.""" + return "required result" + + @mcp.tool(task=TaskConfig(mode="forbidden")) + async def forbidden_tool() -> str: + """Tool that forbids task execution.""" + return "forbidden result" + + @mcp.tool(task=TaskConfig(mode="optional")) + async def optional_tool() -> str: + """Tool that supports both modes.""" + return "optional result" + + return mcp + + async def test_required_mode_without_task_returns_error(self, server): + """Required mode raises error when called without task metadata.""" + async with Client(server) as client: + with pytest.raises(ToolError) as exc_info: + await client.call_tool("required_tool", {}) + + assert "requires task-augmented execution" in str(exc_info.value) + + async def test_required_mode_with_task_succeeds(self, server): + """Required mode succeeds when called with task metadata.""" + async with Client(server) as client: + task = await client.call_tool("required_tool", {}, task=True) + assert task is not None + result = await task.result() + assert result.data == "required result" + + async def test_forbidden_mode_with_task_returns_error(self, server): + """Forbidden mode returns error when called with task metadata.""" + async with Client(server) as client: + # Call with task=True should fail + task = await client.call_tool( + "forbidden_tool", {}, task=True, raise_on_error=False + ) + assert task is not None + # The task should have returned immediately with an error + assert task.returned_immediately + result = await task.result() + # Check for error in the result + assert result.is_error + + async def test_forbidden_mode_without_task_succeeds(self, server): + """Forbidden mode succeeds when called without task metadata.""" + async with Client(server) as client: + result = await client.call_tool("forbidden_tool", {}) + assert "forbidden result" in str(result) + + async def test_optional_mode_without_task_succeeds(self, server): + """Optional mode succeeds when called without task metadata.""" + async with Client(server) as client: + result = await client.call_tool("optional_tool", {}) + assert "optional result" in str(result) + + async def test_optional_mode_with_task_succeeds(self, server): + """Optional mode succeeds when called with task metadata.""" + async with Client(server) as client: + task = await client.call_tool("optional_tool", {}, task=True) + assert task is not None + result = await task.result() + assert result.data == "optional result" + + +class TestResourceModeEnforcement: + """Test mode enforcement for resources.""" + + @pytest.fixture + def server(self): + """Create server with resources in different modes.""" + mcp = FastMCP("test", tasks=False) + + @mcp.resource("resource://required", task=TaskConfig(mode="required")) + async def required_resource() -> str: + """Resource that requires task execution.""" + return "required content" + + @mcp.resource("resource://forbidden", task=TaskConfig(mode="forbidden")) + async def forbidden_resource() -> str: + """Resource that forbids task execution.""" + return "forbidden content" + + @mcp.resource("resource://optional", task=TaskConfig(mode="optional")) + async def optional_resource() -> str: + """Resource that supports both modes.""" + return "optional content" + + return mcp + + async def test_required_resource_without_task_returns_error(self, server): + """Required mode returns error when read without task metadata.""" + from mcp_types import METHOD_NOT_FOUND + + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("resource://required") + + assert exc_info.value.error.code == METHOD_NOT_FOUND + assert "requires task-augmented execution" in exc_info.value.error.message + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_required_resource_with_task_succeeds(self, server): + """Required mode succeeds when read with task metadata.""" + async with Client(server) as client: + task = await client.read_resource("resource://required", task=True) + assert task is not None + result = await task.result() + # Result is a list of resource contents + assert "required content" in str(result) + + async def test_forbidden_resource_without_task_succeeds(self, server): + """Forbidden mode succeeds when read without task metadata.""" + async with Client(server) as client: + result = await client.read_resource("resource://forbidden") + assert "forbidden content" in str(result) + + +class TestPromptModeEnforcement: + """Test mode enforcement for prompts.""" + + @pytest.fixture + def server(self): + """Create server with prompts in different modes.""" + mcp = FastMCP("test", tasks=False) + + @mcp.prompt(task=TaskConfig(mode="required")) + async def required_prompt() -> str: + """Prompt that requires task execution.""" + return "required message" + + @mcp.prompt(task=TaskConfig(mode="forbidden")) + async def forbidden_prompt() -> str: + """Prompt that forbids task execution.""" + return "forbidden message" + + @mcp.prompt(task=TaskConfig(mode="optional")) + async def optional_prompt() -> str: + """Prompt that supports both modes.""" + return "optional message" + + return mcp + + async def test_required_prompt_without_task_returns_error(self, server): + """Required mode returns error when called without task metadata.""" + from mcp_types import METHOD_NOT_FOUND + + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.get_prompt("required_prompt") + + assert exc_info.value.error.code == METHOD_NOT_FOUND + assert "requires task-augmented execution" in exc_info.value.error.message + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_required_prompt_with_task_succeeds(self, server): + """Required mode succeeds when called with task metadata.""" + async with Client(server) as client: + task = await client.get_prompt("required_prompt", task=True) + assert task is not None + result = await task.result() + # Result contains the prompt messages + assert "required message" in str(result) + + async def test_forbidden_prompt_without_task_succeeds(self, server): + """Forbidden mode succeeds when called without task metadata.""" + async with Client(server) as client: + result = await client.get_prompt("forbidden_prompt") + assert isinstance(result.messages[0].content, TextContent) + assert "forbidden message" in str(result.messages[0].content) + + +class TestToolExecutionMetadata: + """Test that ToolExecution.task_support is set correctly in tool metadata.""" + + async def test_optional_tool_exposes_task_support(self): + """Tools with task enabled should expose taskSupport in metadata.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=TaskConfig(mode="optional")) + async def my_tool() -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "my_tool") + assert isinstance(tool, MCPTool) + assert isinstance(tool.execution, ToolExecution) + assert tool.execution.task_support == "optional" + + async def test_required_tool_exposes_task_support(self): + """Tools with mode=required should expose task_support='required'.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=TaskConfig(mode="required")) + async def my_tool() -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "my_tool") + assert isinstance(tool, MCPTool) + assert isinstance(tool.execution, ToolExecution) + assert tool.execution.task_support == "required" + + async def test_forbidden_tool_has_no_execution(self): + """Tools with mode=forbidden should not expose execution metadata.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=TaskConfig(mode="forbidden")) + async def my_tool() -> str: + return "ok" + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "my_tool") + assert tool.execution is None + + +class TestSyncFunctionValidation: + """Test that sync functions cannot have task execution enabled.""" + + def test_sync_function_with_task_true_raises(self): + """Sync functions should raise ValueError when task=True.""" + mcp = FastMCP("test", tasks=False) + + with pytest.raises(ValueError, match="sync function"): + + @mcp.tool(task=True) + def sync_tool() -> str: + return "ok" + + def test_sync_function_with_required_mode_raises(self): + """Sync functions should raise ValueError with mode='required'.""" + mcp = FastMCP("test", tasks=False) + + with pytest.raises(ValueError, match="sync function"): + + @mcp.tool(task=TaskConfig(mode="required")) + def sync_tool() -> str: + return "ok" + + def test_sync_function_with_optional_mode_raises(self): + """Sync functions should raise ValueError with mode='optional'.""" + mcp = FastMCP("test", tasks=False) + + with pytest.raises(ValueError, match="sync function"): + + @mcp.tool(task=TaskConfig(mode="optional")) + def sync_tool() -> str: + return "ok" + + async def test_sync_function_with_forbidden_mode_ok(self): + """Sync functions should work fine with mode='forbidden'.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=TaskConfig(mode="forbidden")) + def sync_tool() -> str: + return "ok" + + tool = await mcp.get_tool("sync_tool") + assert isinstance(tool, Tool) + assert tool.task_config.mode == "forbidden" + + +class TestPollIntervalConfiguration: + """Test poll_interval configuration in TaskConfig.""" + + async def test_default_poll_interval_is_5_seconds(self): + """Default poll_interval should be 5 seconds.""" + config = TaskConfig() + assert config.poll_interval == timedelta(seconds=5) + + async def test_custom_poll_interval_preserved(self): + """Custom poll_interval should be preserved in TaskConfig.""" + config = TaskConfig(poll_interval=timedelta(seconds=10)) + assert config.poll_interval == timedelta(seconds=10) + + async def test_tool_inherits_poll_interval(self): + """Tool should inherit poll_interval from TaskConfig.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=2))) + async def my_tool() -> str: + return "ok" + + tool = await mcp.get_tool("my_tool") + assert isinstance(tool, Tool) + assert tool.task_config.poll_interval == timedelta(seconds=2) + + async def test_task_true_uses_default_poll_interval(self): + """task=True should use default 5 second poll_interval.""" + mcp = FastMCP("test", tasks=False) + + @mcp.tool(task=True) + async def my_tool() -> str: + return "ok" + + tool = await mcp.get_tool("my_tool") + assert isinstance(tool, Tool) + assert tool.task_config.poll_interval == timedelta(seconds=5) diff --git a/tests/server/tasks/test_task_dependencies.py b/tests/server/tasks/test_task_dependencies.py new file mode 100644 index 000000000..0aef545ee --- /dev/null +++ b/tests/server/tasks/test_task_dependencies.py @@ -0,0 +1,284 @@ +"""Tests for dependency injection in background tasks. + +These tests verify that Docket's dependency system works correctly when +user functions are queued as background tasks. Dependencies like CurrentDocket(), +CurrentFastMCP(), and Depends() should be resolved in the worker context. +""" + +from contextlib import asynccontextmanager +from typing import Any, cast + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.dependencies import CurrentDocket, CurrentFastMCP, Depends +from fastmcp.exceptions import ToolError + + +@pytest.fixture +async def dependency_server(): + """Create a FastMCP server with dependency-using background tasks.""" + mcp = FastMCP("dependency-test-server") + + # Track dependency injection + injected_values = [] + + @mcp.tool(task=True) + async def tool_with_docket_dependency(docket=CurrentDocket()) -> str: + """Background tool that uses CurrentDocket dependency.""" + injected_values.append(("docket", docket)) + return f"Docket: {docket is not None}" + + @mcp.tool(task=True) + async def tool_with_server_dependency(server=CurrentFastMCP()) -> str: + """Background tool that uses CurrentFastMCP dependency.""" + injected_values.append(("server", server)) + return f"Server: {server.name}" + + @mcp.tool(task=True) + async def tool_with_custom_dependency( + value: int, multiplier: int = Depends(lambda: 10) + ) -> int: + """Background tool with custom Depends().""" + injected_values.append(("multiplier", multiplier)) + return value * multiplier + + @mcp.tool(task=True) + async def tool_with_multiple_dependencies( + name: str, + docket=CurrentDocket(), + server=CurrentFastMCP(), + ) -> str: + """Background tool with multiple dependencies.""" + injected_values.append(("multi_docket", docket)) + injected_values.append(("multi_server", server)) + return f"{name} on {server.name}" + + @mcp.prompt(task=True) + async def prompt_with_server_dependency(topic: str, server=CurrentFastMCP()) -> str: + """Background prompt that uses CurrentFastMCP dependency.""" + injected_values.append(("prompt_server", server)) + return f"Prompt from {server.name} about {topic}" + + @mcp.resource("file://data.txt", task=True) + async def resource_with_docket_dependency(docket=CurrentDocket()) -> str: + """Background resource that uses CurrentDocket dependency.""" + injected_values.append(("resource_docket", docket)) + return f"Resource via Docket: {docket is not None}" + + # Expose for test assertions + mcp._injected_values = injected_values # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + return mcp + + +async def test_background_tool_receives_docket_dependency(dependency_server): + """Background tools can use CurrentDocket() and it resolves correctly.""" + async with Client(dependency_server) as client: + task = await client.call_tool("tool_with_docket_dependency", {}, task=True) + + # Verify it's background + assert not task.returned_immediately + + # Get result - will execute in Docket worker + result = await task + + # Verify dependency was injected + assert len(dependency_server._injected_values) == 1 + dep_type, dep_value = dependency_server._injected_values[0] + assert dep_type == "docket" + assert dep_value is not None + assert "Docket: True" in result.data + + +async def test_background_tool_receives_server_dependency(dependency_server): + """Background tools can use CurrentFastMCP() and get the actual FastMCP server.""" + dependency_server._injected_values.clear() + + async with Client(dependency_server) as client: + task = await client.call_tool("tool_with_server_dependency", {}, task=True) + + # Verify background execution + assert not task.returned_immediately + + result = await task + + # Check the server instance was injected + assert len(dependency_server._injected_values) == 1 + dep_type, dep_value = dependency_server._injected_values[0] + assert dep_type == "server" + assert dep_value is dependency_server # Same instance! + assert f"Server: {dependency_server.name}" in result.data + + +async def test_background_tool_receives_custom_depends(dependency_server): + """Background tools can use Depends() with custom functions.""" + dependency_server._injected_values.clear() + + async with Client(dependency_server) as client: + task = await client.call_tool( + "tool_with_custom_dependency", {"value": 5}, task=True + ) + + assert not task.returned_immediately + + result = await task + + # Check dependency was resolved + assert len(dependency_server._injected_values) == 1 + dep_type, dep_value = dependency_server._injected_values[0] + assert dep_type == "multiplier" + assert dep_value == 10 + assert result.data == 50 # 5 * 10 + + +async def test_background_tool_with_multiple_dependencies(dependency_server): + """Background tools can have multiple dependencies injected simultaneously.""" + dependency_server._injected_values.clear() + + async with Client(dependency_server) as client: + task = await client.call_tool( + "tool_with_multiple_dependencies", {"name": "test"}, task=True + ) + + assert not task.returned_immediately + + await task + + # Both dependencies should be injected + assert len(dependency_server._injected_values) == 2 + + dep_types = {item[0] for item in dependency_server._injected_values} + assert "multi_docket" in dep_types + assert "multi_server" in dep_types + + # Verify values + server_dep = next( + v for t, v in dependency_server._injected_values if t == "multi_server" + ) + assert server_dep is dependency_server + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_background_prompt_receives_dependencies(dependency_server): + """Background prompts can use dependency injection.""" + dependency_server._injected_values.clear() + + async with Client(dependency_server) as client: + task = await client.get_prompt( + "prompt_with_server_dependency", {"topic": "AI"}, task=True + ) + + assert not task.returned_immediately + + await task + + # Check dependency was injected + assert len(dependency_server._injected_values) == 1 + dep_type, dep_value = dependency_server._injected_values[0] + assert dep_type == "prompt_server" + assert dep_value is dependency_server + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_background_resource_receives_dependencies(dependency_server): + """Background resources can use dependency injection.""" + dependency_server._injected_values.clear() + + async with Client(dependency_server) as client: + task = await client.read_resource("file://data.txt", task=True) + + assert not task.returned_immediately + + await task + + # Check dependency was injected + assert len(dependency_server._injected_values) == 1 + dep_type, dep_value = dependency_server._injected_values[0] + assert dep_type == "resource_docket" + assert dep_value is not None + + +async def test_foreground_tool_dependencies_unaffected(dependency_server): + """Synchronous tools (task=False) still get dependencies as before.""" + dependency_server._injected_values.clear() + + @dependency_server.tool() # task=False + async def sync_tool(server=CurrentFastMCP()) -> str: + dependency_server._injected_values.append(("sync_server", server)) + return f"Sync: {server.name}" + + async with Client(dependency_server) as client: + await client.call_tool("sync_tool", {}) + + # Should execute immediately + assert len(dependency_server._injected_values) == 1 + assert dependency_server._injected_values[0][1] is dependency_server + + +async def test_dependency_context_managers_cleaned_up_in_background(): + """Context manager dependencies are properly cleaned up after background task.""" + cleanup_called = [] + + mcp = FastMCP("cleanup-test") + + @asynccontextmanager + async def tracked_connection(): + try: + cleanup_called.append("enter") + yield "connection" + finally: + cleanup_called.append("exit") + + @mcp.tool(task=True) + async def use_connection(name: str, conn: str = Depends(tracked_connection)) -> str: + assert conn == "connection" + assert "enter" in cleanup_called + assert "exit" not in cleanup_called # Still open during execution + return f"Used: {conn}" + + async with Client(mcp) as client: + task = await client.call_tool("use_connection", {"name": "test"}, task=True) + result = await task + + # After task completes, cleanup should have been called + assert cleanup_called == ["enter", "exit"] + assert "Used: connection" in result.data + + +async def test_dependency_errors_propagate_to_task_failure(): + """If dependency resolution fails, the background task should fail.""" + mcp = FastMCP("error-test") + + async def failing_dependency(): + raise ValueError("Dependency failed!") + + @mcp.tool(task=True) + async def tool_with_failing_dep( + value: str, dep: str = cast(Any, Depends(failing_dependency)) + ) -> str: + return f"Got: {dep}" + + async with Client(mcp) as client: + task = await client.call_tool( + "tool_with_failing_dep", {"value": "test"}, task=True + ) + + # Task should fail due to dependency error + with pytest.raises(ToolError, match="Failed to resolve dependencies"): + await task.result() + + # Verify it reached failed state + status = await task.status() + assert status.status == "failed" diff --git a/tests/server/tasks/test_task_elicitation_relay.py b/tests/server/tasks/test_task_elicitation_relay.py new file mode 100644 index 000000000..42362edd2 --- /dev/null +++ b/tests/server/tasks/test_task_elicitation_relay.py @@ -0,0 +1,191 @@ +"""Tests for background task elicitation relay (notifications.py). + +The relay bridges distributed background tasks to clients via the standard +MCP elicitation/create protocol. When a worker calls ctx.elicit(), the +notification subscriber detects the input_required notification and sends +an elicitation/create request to the client session. The client's +elicitation_handler fires, and the relay pushes the response to Redis +for the blocked worker. + +These tests use Client(mcp) with the real memory:// Docket backend. +""" + +import asyncio +from dataclasses import dataclass + +from pydantic import BaseModel + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.elicitation import ElicitResult +from fastmcp.server.context import Context +from fastmcp.server.elicitation import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, +) + + +class TestElicitationRelay: + """E2E tests for elicitation flowing through the standard MCP protocol.""" + + async def test_accept_via_elicitation_handler(self): + """Tool elicits, client handler accepts, tool gets the value.""" + mcp = FastMCP("relay-accept") + + @mcp.tool(task=True) + async def ask_name(ctx: Context) -> str: + result = await ctx.elicit("What is your name?", str) + if isinstance(result, AcceptedElicitation): + return f"Hello, {result.data}!" + return "No name" + + async def handler(message, response_type, params, ctx): + assert message == "What is your name?" + return ElicitResult(action="accept", content={"value": "Alice"}) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("ask_name", {}, task=True) + result = await task.result() + assert result.data == "Hello, Alice!" + + async def test_decline_via_elicitation_handler(self): + """Tool elicits, client handler declines, tool gets DeclinedElicitation.""" + mcp = FastMCP("relay-decline") + + @mcp.tool(task=True) + async def optional_input(ctx: Context) -> str: + result = await ctx.elicit("Provide a name?", str) + if isinstance(result, DeclinedElicitation): + return "User declined" + if isinstance(result, AcceptedElicitation): + return f"Got: {result.data}" + return "Cancelled" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="decline") + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("optional_input", {}, task=True) + result = await task.result() + assert result.data == "User declined" + + async def test_cancel_via_elicitation_handler(self): + """Tool elicits, client handler cancels, tool gets CancelledElicitation.""" + mcp = FastMCP("relay-cancel") + + @mcp.tool(task=True) + async def cancellable(ctx: Context) -> str: + result = await ctx.elicit("Input?", str) + if isinstance(result, CancelledElicitation): + return "Cancelled" + return "Not cancelled" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="cancel") + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("cancellable", {}, task=True) + result = await task.result() + assert result.data == "Cancelled" + + async def test_dataclass_round_trips_through_relay(self): + """Structured dataclass type round-trips through the relay.""" + mcp = FastMCP("relay-dataclass") + + @dataclass + class UserInfo: + name: str + age: int + + @mcp.tool(task=True) + async def get_user(ctx: Context) -> str: + result = await ctx.elicit("Provide user info", UserInfo) + if isinstance(result, AcceptedElicitation): + assert isinstance(result.data, UserInfo) + return f"{result.data.name} is {result.data.age}" + return "No info" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"name": "Bob", "age": 30}) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("get_user", {}, task=True) + result = await task.result() + assert result.data == "Bob is 30" + + async def test_pydantic_model_round_trips_through_relay(self): + """Structured Pydantic model round-trips through the relay.""" + mcp = FastMCP("relay-pydantic") + + class Config(BaseModel): + host: str + port: int + + @mcp.tool(task=True) + async def get_config(ctx: Context) -> str: + result = await ctx.elicit("Server config?", Config) + if isinstance(result, AcceptedElicitation): + assert isinstance(result.data, Config) + return f"{result.data.host}:{result.data.port}" + return "No config" + + async def handler(message, response_type, params, ctx): + return ElicitResult( + action="accept", content={"host": "localhost", "port": 8080} + ) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("get_config", {}, task=True) + result = await task.result() + assert result.data == "localhost:8080" + + async def test_multiple_sequential_elicitations(self): + """Tool calls ctx.elicit() twice, both go through the relay.""" + mcp = FastMCP("relay-multi") + + @mcp.tool(task=True) + async def two_questions(ctx: Context) -> str: + r1 = await ctx.elicit("First name?", str) + r2 = await ctx.elicit("Last name?", str) + if isinstance(r1, AcceptedElicitation) and isinstance( + r2, AcceptedElicitation + ): + return f"{r1.data} {r2.data}" + return "Incomplete" + + call_count = 0 + + async def handler(message, response_type, params, ctx): + nonlocal call_count + call_count += 1 + if call_count == 1: + assert message == "First name?" + return ElicitResult(action="accept", content={"value": "Jane"}) + else: + assert message == "Last name?" + return ElicitResult(action="accept", content={"value": "Doe"}) + + async with Client(mcp, elicitation_handler=handler) as client: + task = await client.call_tool("two_questions", {}, task=True) + result = await task.result() + assert result.data == "Jane Doe" + assert call_count == 2 + + async def test_no_elicitation_handler_returns_cancel(self): + """Without an elicitation_handler, the relay fails and task gets cancel.""" + mcp = FastMCP("relay-no-handler") + + @mcp.tool(task=True) + async def needs_input(ctx: Context) -> str: + result = await ctx.elicit("Input?", str) + if isinstance(result, CancelledElicitation): + return "Cancelled as expected" + if isinstance(result, AcceptedElicitation): + return f"Got: {result.data}" + return "Other" + + async with Client(mcp) as client: + task = await client.call_tool("needs_input", {}, task=True) + result = await asyncio.wait_for(task.result(), timeout=15.0) + assert result.data == "Cancelled as expected" diff --git a/tests/tasks/server/test_task_keys.py b/tests/server/tasks/test_task_keys.py similarity index 99% rename from tests/tasks/server/test_task_keys.py rename to tests/server/tasks/test_task_keys.py index 7414cfdba..06a64f8f1 100644 --- a/tests/tasks/server/test_task_keys.py +++ b/tests/server/tasks/test_task_keys.py @@ -9,7 +9,8 @@ the Docket-key prefix and the Redis-key prefix. """ import pytest -from fastmcp_tasks.keys import ( + +from fastmcp.server.tasks.keys import ( build_task_key, get_client_task_id_from_key, parse_task_key, diff --git a/tests/server/tasks/test_task_meta_parameter.py b/tests/server/tasks/test_task_meta_parameter.py new file mode 100644 index 000000000..e76930637 --- /dev/null +++ b/tests/server/tasks/test_task_meta_parameter.py @@ -0,0 +1,314 @@ +""" +Tests for the explicit task_meta parameter on FastMCP.call_tool(). + +These tests verify that the task_meta parameter provides explicit control +over sync vs task execution, replacing implicit contextvar-based behavior. +""" + +import mcp_types +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.exceptions import ToolError +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.server.tasks.config import TaskMeta +from fastmcp.tools.base import Tool, ToolResult + + +class TestTaskMetaParameter: + """Tests for task_meta parameter on FastMCP.call_tool().""" + + async def test_task_meta_none_returns_tool_result(self): + """With task_meta=None (default), call_tool returns ToolResult.""" + server = FastMCP("test") + + @server.tool + async def simple_tool(x: int) -> int: + return x * 2 + + result = await server.call_tool("simple_tool", {"x": 5}) + + first_content = result.content[0] + assert isinstance(first_content, mcp_types.TextContent) + assert first_content.text == "10" + + async def test_task_meta_none_on_task_enabled_tool_still_returns_tool_result(self): + """Even for task=True tools, task_meta=None returns ToolResult synchronously.""" + server = FastMCP("test") + + @server.tool(task=True) + async def task_enabled_tool(x: int) -> int: + return x * 2 + + # Without task_meta, should execute synchronously + result = await server.call_tool("task_enabled_tool", {"x": 5}) + + first_content = result.content[0] + assert isinstance(first_content, mcp_types.TextContent) + assert first_content.text == "10" + + async def test_task_meta_on_forbidden_tool_raises_error(self): + """Providing task_meta to a task=False tool raises ToolError.""" + server = FastMCP("test") + + @server.tool(task=False) + async def sync_only_tool(x: int) -> int: + return x * 2 + + # Error is raised before docket is needed (MCPError wrapped as ToolError) + with pytest.raises(ToolError) as exc_info: + await server.call_tool("sync_only_tool", {"x": 5}, task_meta=TaskMeta()) + + assert "does not support task-augmented execution" in str(exc_info.value) + + async def test_task_meta_fn_key_auto_populated_in_call_tool(self): + """fn_key is auto-populated from tool name in call_tool().""" + server = FastMCP("test") + + @server.tool(task=True) + async def auto_key_tool() -> str: + return "done" + + # Verify fn_key starts as None + task_meta = TaskMeta() + assert task_meta.fn_key is None + + # call_tool enriches the task_meta before passing to _run + # We test this via the client integration path + async with Client(server) as client: + result = await client.call_tool("auto_key_tool", {}, task=True) + # Should succeed because fn_key was auto-populated + from fastmcp.client.tasks import ToolTask + + assert isinstance(result, ToolTask) + + async def test_task_meta_fn_key_enrichment_logic(self): + """Verify that fn_key enrichment uses Tool.make_key().""" + # Direct test of the enrichment logic + tool_name = "my_tool" + expected_key = Tool.make_key(tool_name) + + assert expected_key == "tool:my_tool" + + +class TestTaskMetaTTL: + """Tests for task_meta.ttl behavior.""" + + async def test_task_with_custom_ttl_creates_task(self): + """task_meta.ttl is passed through when creating tasks.""" + server = FastMCP("test") + + @server.tool(task=True) + async def ttl_tool() -> str: + return "done" + + custom_ttl_ms = 30000 # 30 seconds + + async with Client(server) as client: + # Use client.call_tool with task=True and ttl + task = await client.call_tool("ttl_tool", {}, task=True, ttl=custom_ttl_ms) + + from fastmcp.client.tasks import ToolTask + + assert isinstance(task, ToolTask) + + # Verify task completes successfully + result = await task.result() + assert "done" in str(result) + + async def test_task_without_ttl_uses_default(self): + """task_meta.ttl=None uses docket.execution_ttl default.""" + server = FastMCP("test") + + @server.tool(task=True) + async def default_ttl_tool() -> str: + return "done" + + async with Client(server) as client: + # Use client.call_tool with task=True, default ttl + task = await client.call_tool("default_ttl_tool", {}, task=True) + + from fastmcp.client.tasks import ToolTask + + assert isinstance(task, ToolTask) + + # Verify task completes successfully + result = await task.result() + assert "done" in str(result) + + +class TrackingMiddleware(Middleware): + """Middleware that tracks tool calls.""" + + def __init__(self, calls: list[str]): + super().__init__() + self._calls = calls + + async def on_call_tool( + self, + context: MiddlewareContext[mcp_types.CallToolRequestParams], + call_next: CallNext[mcp_types.CallToolRequestParams, ToolResult], + ) -> ToolResult: + if context.method: + self._calls.append(context.method) + return await call_next(context) + + +class TestTaskMetaMiddleware: + """Tests that task_meta is properly propagated through middleware.""" + + async def test_task_meta_propagated_through_middleware(self): + """task_meta is passed through middleware chain.""" + server = FastMCP("test") + middleware_saw_request: list[str] = [] + + @server.tool(task=True) + async def middleware_test_tool() -> str: + return "done" + + server.add_middleware(TrackingMiddleware(middleware_saw_request)) + + async with Client(server) as client: + # Use client to trigger the middleware chain + task = await client.call_tool("middleware_test_tool", {}, task=True) + + # Middleware should have run + assert "tools/call" in middleware_saw_request + + # And task should have been created + from fastmcp.client.tasks import ToolTask + + assert isinstance(task, ToolTask) + + +class TestTaskMetaClientIntegration: + """Tests that task_meta works correctly with the Client.""" + + async def test_client_task_true_maps_to_task_meta(self): + """Client's task=True creates proper task_meta on server.""" + server = FastMCP("test") + + @server.tool(task=True) + async def client_test_tool(x: int) -> int: + return x * 2 + + async with Client(server) as client: + # Client passes task=True, server receives as task_meta + task = await client.call_tool("client_test_tool", {"x": 5}, task=True) + + # Should get back a ToolTask (client wrapper) + from fastmcp.client.tasks import ToolTask + + assert isinstance(task, ToolTask) + + # Wait for result + result = await task.result() + assert "10" in str(result) + + async def test_client_without_task_gets_immediate_result(self): + """Client without task=True gets immediate result.""" + server = FastMCP("test") + + @server.tool(task=True) + async def immediate_tool(x: int) -> int: + return x * 2 + + async with Client(server) as client: + # No task=True, should execute synchronously + result = await client.call_tool("immediate_tool", {"x": 5}) + + # Should get CallToolResult directly + assert "10" in str(result) + + async def test_client_task_with_custom_ttl(self): + """Client can pass custom TTL for task execution.""" + server = FastMCP("test") + + @server.tool(task=True) + async def custom_ttl_tool() -> str: + return "done" + + custom_ttl_ms = 60000 # 60 seconds + + async with Client(server) as client: + task = await client.call_tool( + "custom_ttl_tool", {}, task=True, ttl=custom_ttl_ms + ) + + from fastmcp.client.tasks import ToolTask + + assert isinstance(task, ToolTask) + + # Verify task completes successfully + result = await task.result() + assert "done" in str(result) + + +class TestTaskMetaDirectServerCall: + """Tests for direct server calls (tool calling another tool).""" + + async def test_tool_can_call_another_tool_with_task(self): + """A tool can call another tool as a background task.""" + server = FastMCP("test") + + @server.tool(task=True) + async def inner_tool(x: int) -> int: + return x * 2 + + @server.tool + async def outer_tool(x: int) -> str: + # Call inner tool as background task + result = await server.call_tool( + "inner_tool", {"x": x}, task_meta=TaskMeta() + ) + # Should get CreateTaskResult since we're in server context + return f"Created task: {result.task.task_id}" + + async with Client(server) as client: + # Call outer_tool which internally calls inner_tool with task_meta + result = await client.call_tool("outer_tool", {"x": 5}) + # The outer tool should have successfully created a background task + assert "Created task:" in str(result) + + async def test_tool_can_call_another_tool_synchronously(self): + """A tool can call another tool synchronously (no task_meta).""" + server = FastMCP("test") + + @server.tool(task=True) + async def inner_tool(x: int) -> int: + return x * 2 + + @server.tool + async def outer_tool(x: int) -> str: + # Call inner tool synchronously (no task_meta) + result = await server.call_tool("inner_tool", {"x": x}) + # Should get ToolResult directly + first_content = result.content[0] + assert isinstance(first_content, mcp_types.TextContent) + return f"Got result: {first_content.text}" + + async with Client(server) as client: + result = await client.call_tool("outer_tool", {"x": 5}) + assert "Got result: 10" in str(result) + + async def test_tool_can_call_another_tool_with_custom_ttl(self): + """A tool can call another tool as a background task with custom TTL.""" + server = FastMCP("test") + + @server.tool(task=True) + async def inner_tool(x: int) -> int: + return x * 2 + + @server.tool + async def outer_tool(x: int) -> str: + custom_ttl = 45000 # 45 seconds + result = await server.call_tool( + "inner_tool", {"x": x}, task_meta=TaskMeta(ttl=custom_ttl) + ) + return f"Task TTL: {result.task.ttl}" + + async with Client(server) as client: + result = await client.call_tool("outer_tool", {"x": 5}) + # The inner tool task should have the custom TTL + assert "Task TTL: 45000" in str(result) diff --git a/tests/server/tasks/test_task_metadata.py b/tests/server/tasks/test_task_metadata.py new file mode 100644 index 000000000..0d8935d36 --- /dev/null +++ b/tests/server/tasks/test_task_metadata.py @@ -0,0 +1,63 @@ +""" +Tests for SEP-1686 related-task metadata in protocol responses. + +Per the spec, all task-related responses MUST include +io.modelcontextprotocol/related-task in _meta. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client + + +@pytest.fixture +async def metadata_server(): + """Create a server for testing metadata.""" + mcp = FastMCP("metadata-test") + + @mcp.tool(task=True) + async def test_tool(value: int) -> int: + return value * 2 + + return mcp + + +async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP): + """tasks/get response includes io.modelcontextprotocol/related-task in _meta.""" + async with Client(metadata_server) as client: + # Submit a task + task = await client.call_tool("test_tool", {"value": 5}, task=True) + task_id = task.task_id + + # Get status via client (which uses protocol properly) + status = await client.get_task_status(task_id) + + # GetTaskResult is returned from response with metadata + # Verify the protocol included related-task metadata by checking the response worked + assert status.task_id == task_id + assert status.status in ["working", "completed"] + + +async def test_tasks_result_includes_related_task_metadata(metadata_server: FastMCP): + """tasks/result response includes io.modelcontextprotocol/related-task in _meta.""" + async with Client(metadata_server) as client: + # Submit and complete a task + task = await client.call_tool("test_tool", {"value": 7}, task=True) + result = await task.result() + + # Result should have metadata (added by task.result() or protocol) + # Just verify the result is valid and contains the expected value + assert result.content + assert result.data == 14 # 7 * 2 + + +async def test_tasks_list_includes_related_task_metadata(metadata_server: FastMCP): + """tasks/list response includes io.modelcontextprotocol/related-task in _meta.""" + async with Client(metadata_server) as client: + # List tasks via client (which uses protocol properly) + result = await client.list_tasks() + + # Verify list_tasks works and returns proper structure + assert "tasks" in result + assert isinstance(result["tasks"], list) diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py new file mode 100644 index 000000000..493bc9167 --- /dev/null +++ b/tests/server/tasks/test_task_methods.py @@ -0,0 +1,218 @@ +""" +Tests for task protocol methods. + +Tests the tasks/get, tasks/result, and tasks/list JSON-RPC protocol methods. +""" + +import asyncio + +import pytest +from mcp.shared.exceptions import MCPError + +from fastmcp import FastMCP +from fastmcp.client import Client + + +@pytest.fixture +async def endpoint_server(): + """Create a server with background tasks and HTTP transport.""" + mcp = FastMCP("endpoint-test-server") + + @mcp.tool(task=True) # Enable background execution + async def quick_tool(value: int) -> int: + """Returns the value immediately.""" + return value * 2 + + @mcp.tool(task=True) # Enable background execution + async def error_tool() -> str: + """Always raises an error.""" + raise RuntimeError("Task failed!") + + @mcp.tool(task=True) # Enable background execution + async def slow_tool() -> str: + """A slow tool for testing cancellation.""" + await asyncio.sleep(10) + return "done" + + return mcp + + +async def test_tasks_get_endpoint_returns_status(endpoint_server): + """POST /tasks/get returns task status.""" + async with Client(endpoint_server) as client: + # Submit a task + task = await client.call_tool("quick_tool", {"value": 21}, task=True) + + # Check status immediately - should be submitted or working + status = await task.status() + assert status.task_id == task.task_id + assert status.status in ["working", "completed"] + + # Wait for completion + await task.wait(timeout=2.0) + + # Check again - should be completed + status = await task.status() + assert status.status == "completed" + + +async def test_tasks_get_endpoint_includes_poll_interval(endpoint_server): + """Task status includes pollFrequency hint.""" + async with Client(endpoint_server) as client: + task = await client.call_tool("quick_tool", {"value": 42}, task=True) + + status = await task.status() + assert status.poll_interval is not None + assert isinstance(status.poll_interval, int) + + +async def test_tasks_result_endpoint_returns_result_when_completed(endpoint_server): + """POST /tasks/result returns the tool result when completed.""" + async with Client(endpoint_server) as client: + task = await client.call_tool("quick_tool", {"value": 21}, task=True) + + # Wait for completion and get result + result = await task.result() + assert result.data == 42 # 21 * 2 + + +async def test_tasks_result_endpoint_errors_if_not_completed(endpoint_server): + """POST /tasks/result returns error if task not completed yet.""" + # Create a task that won't complete until signaled + completion_signal = asyncio.Event() + + @endpoint_server.tool(task=True) # Enable background execution + async def blocked_tool() -> str: + await completion_signal.wait() + return "done" + + async with Client(endpoint_server) as client: + task = await client.call_tool("blocked_tool", task=True) + + # Try to get result immediately (task still running) + with pytest.raises(Exception): # Should raise or return error + await client.get_task_result(task.task_id) + + # Cleanup - signal completion + completion_signal.set() + + +async def test_tasks_result_endpoint_errors_if_task_not_found(endpoint_server): + """POST /tasks/result returns error for non-existent task.""" + async with Client(endpoint_server) as client: + # Try to get result for non-existent task + with pytest.raises(Exception): + await client.get_task_result("non-existent-task-id") + + +async def test_tasks_result_endpoint_returns_error_for_failed_task(endpoint_server): + """POST /tasks/result returns error information for failed tasks.""" + async with Client(endpoint_server) as client: + task = await client.call_tool("error_tool", task=True) + + # Wait for task to fail + await task.wait(state="failed", timeout=2.0) + + # Getting result should raise or return error info + with pytest.raises(Exception) as exc_info: + await task.result() + + assert ( + "failed" in str(exc_info.value).lower() + or "error" in str(exc_info.value).lower() + ) + + +async def test_tasks_list_endpoint_session_isolation(endpoint_server): + """list_tasks returns only tasks submitted by this client.""" + # Since client tracks tasks locally, this tests client-side tracking + async with Client(endpoint_server) as client: + # Submit multiple tasks (server generates IDs) + tasks = [] + for i in range(3): + task = await client.call_tool("quick_tool", {"value": i}, task=True) + tasks.append(task) + + # Wait for all to complete + for task in tasks: + await task.wait(timeout=2.0) + + # List tasks - should see all 3 + response = await client.list_tasks() + returned_ids = [t["taskId"] for t in response["tasks"]] + task_ids = [t.task_id for t in tasks] + assert len(returned_ids) == 3 + assert all(tid in task_ids for tid in returned_ids) + + +async def test_get_status_nonexistent_task_raises_error(endpoint_server): + """Getting status for nonexistent task raises MCP error (per SEP-1686 SDK behavior).""" + async with Client(endpoint_server) as client: + # Try to get status for task that was never created + # Per SDK implementation: raises ValueError which becomes JSON-RPC error + with pytest.raises(MCPError, match="Task nonexistent-task-id not found"): + await client.get_task_status("nonexistent-task-id") + + +async def test_task_cancellation_workflow(endpoint_server): + """Task can be cancelled, transitioning to cancelled state.""" + async with Client(endpoint_server) as client: + # Submit slow task + task = await client.call_tool("slow_tool", {}, task=True) + + # Give it a moment to start + await asyncio.sleep(0.1) + + # Cancel the task + await task.cancel() + + # Give cancellation a moment to process + await asyncio.sleep(0.1) + + # Task should be in cancelled state + status = await task.status() + assert status.status == "cancelled" + + +@pytest.mark.timeout(10) +async def test_task_cancellation_interrupts_running_coroutine(endpoint_server): + """Task cancellation actually interrupts the running coroutine. + + This verifies that when a task is cancelled, the underlying asyncio + coroutine receives CancelledError rather than continuing to completion. + Requires pydocket >= 0.16.2. + + See: https://github.com/PrefectHQ/fastmcp/issues/2679 + """ + started = asyncio.Event() + was_interrupted = asyncio.Event() + completed_normally = asyncio.Event() + + @endpoint_server.tool(task=True) + async def interruptible_tool() -> str: + started.set() + try: + await asyncio.sleep(60) + completed_normally.set() + return "completed" + except asyncio.CancelledError: + was_interrupted.set() + raise + + async with Client(endpoint_server) as client: + task = await client.call_tool("interruptible_tool", {}, task=True) + + # Wait for the tool to actually start executing + await asyncio.wait_for(started.wait(), timeout=5.0) + + # Cancel the task + await task.cancel() + + # Wait for cancellation to propagate + await asyncio.wait_for(was_interrupted.wait(), timeout=5.0) + + # The coroutine should have been interrupted, not completed normally + assert was_interrupted.is_set(), "Task was not interrupted by cancellation" + assert not completed_normally.is_set(), ( + "Task completed instead of being cancelled" + ) diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py new file mode 100644 index 000000000..0401259c1 --- /dev/null +++ b/tests/server/tasks/test_task_mount.py @@ -0,0 +1,1124 @@ +""" +Tests for MCP SEP-1686 task protocol support through mounted servers. + +Verifies that tasks work seamlessly when calling tools/prompts/resources +on mounted child servers through a parent server. +""" + +import asyncio + +import mcp_types as mt +import pytest +from docket import Docket +from mcp_types import Tool as MCPTool +from mcp_types import ToolExecution + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.prompts.base import PromptResult +from fastmcp.resources.base import ResourceResult +from fastmcp.server.dependencies import CurrentDocket, CurrentFastMCP +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.server.providers.proxy import ProxyTool +from fastmcp.server.tasks import TaskConfig +from fastmcp.tools.base import ToolResult + + +@pytest.fixture(autouse=True) +def reset_docket_memory_server(): + """Reset the shared Docket memory server between tests. + + Docket uses a class-level FakeServer instance for memory:// URLs which + persists between tests, causing test isolation issues. This fixture + clears that shared state before each test. + """ + # Clear the shared FakeServer before each test + if hasattr(Docket, "_memory_server"): + delattr(Docket, "_memory_server") + yield + # Clean up after test as well + if hasattr(Docket, "_memory_server"): + delattr(Docket, "_memory_server") + + +@pytest.fixture +def child_server(): + """Create a child server with task-enabled components.""" + mcp = FastMCP("child-server") + + @mcp.tool(task=True) + async def multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b + + @mcp.tool(task=True) + async def slow_child_tool(duration: float = 0.1) -> str: + """A child tool that takes time to execute.""" + await asyncio.sleep(duration) + return "child completed" + + @mcp.tool(task=False) + async def sync_child_tool(message: str) -> str: + """Child tool that only supports synchronous execution.""" + return f"child sync: {message}" + + @mcp.prompt(task=True) + async def child_prompt(topic: str) -> str: + """A child prompt that can execute as a task.""" + return f"Here is information about {topic} from the child server." + + @mcp.resource("child://data.txt", task=True) + async def child_resource() -> str: + """A child resource that can be read as a task.""" + return "Data from child server" + + @mcp.resource("child://item/{item_id}.json", task=True) + async def child_item_resource(item_id: str) -> str: + """A child resource template that can execute as a task.""" + return f'{{"itemId": "{item_id}", "source": "child"}}' + + return mcp + + +@pytest.fixture +def parent_server(child_server): + """Create a parent server with the child mounted.""" + parent = FastMCP("parent-server") + + @parent.tool(task=True) + async def parent_tool(value: int) -> int: + """A tool on the parent server.""" + return value * 10 + + # Mount child with prefix + parent.mount(child_server, namespace="child") + + return parent + + +@pytest.fixture +def parent_server_no_prefix(child_server): + """Create a parent server with child mounted without prefix.""" + parent = FastMCP("parent-no-prefix") + parent.mount(child_server) # No prefix + return parent + + +class TestMountedToolTasks: + """Test task execution for mounted tools.""" + + async def test_mounted_tool_task_returns_task_object(self, parent_server): + """Mounted tool called with task=True returns a task object.""" + async with Client(parent_server) as client: + # Tool name is prefixed: child_multiply + task = await client.call_tool("child_multiply", {"a": 6, "b": 7}, task=True) + + assert task is not None + assert hasattr(task, "task_id") + assert isinstance(task.task_id, str) + assert len(task.task_id) > 0 + + async def test_mounted_tool_task_executes_in_background(self, parent_server): + """Mounted tool task executes in background.""" + async with Client(parent_server) as client: + task = await client.call_tool("child_multiply", {"a": 3, "b": 4}, task=True) + + # Should execute in background + assert not task.returned_immediately + + async def test_mounted_tool_task_returns_correct_result( + self, parent_server: FastMCP + ): + """Mounted tool task returns correct result.""" + async with Client(parent_server) as client: + task = await client.call_tool("child_multiply", {"a": 8, "b": 9}, task=True) + + result = await task.result() + assert result.data == 72 + + async def test_mounted_tool_task_status(self, parent_server): + """Can poll task status for mounted tool.""" + async with Client(parent_server) as client: + task = await client.call_tool( + "child_slow_child_tool", {"duration": 0.5}, task=True + ) + + # Check status while running + status = await task.status() + assert status.status in ["working", "completed"] + + # Wait for completion + await task.wait(timeout=2.0) + + # Check status after completion + status = await task.status() + assert status.status == "completed" + + @pytest.mark.timeout(10) + async def test_mounted_tool_task_cancellation(self, parent_server): + """Can cancel a mounted tool task.""" + async with Client(parent_server) as client: + task = await client.call_tool( + "child_slow_child_tool", {"duration": 10.0}, task=True + ) + + # Let it start + await asyncio.sleep(0.1) + + # Cancel the task + await task.cancel() + + # Check status + status = await task.status() + assert status.status == "cancelled" + + async def test_graceful_degradation_sync_mounted_tool(self, parent_server): + """Sync-only mounted tool returns error with task=True.""" + async with Client(parent_server) as client: + task = await client.call_tool( + "child_sync_child_tool", + {"message": "hello"}, + task=True, + raise_on_error=False, + ) + + # Should return immediately with an error + assert task.returned_immediately + + result = await task.result() + assert result.is_error + + async def test_parent_and_mounted_tools_both_work(self, parent_server): + """Both parent and mounted tools work as tasks.""" + async with Client(parent_server) as client: + # Parent tool + parent_task = await client.call_tool("parent_tool", {"value": 5}, task=True) + # Mounted tool + child_task = await client.call_tool( + "child_multiply", {"a": 2, "b": 3}, task=True + ) + + parent_result = await parent_task.result() + child_result = await child_task.result() + + assert parent_result.data == 50 + assert child_result.data == 6 + + +class TestMountedToolTasksNoPrefix: + """Test task execution for mounted tools without prefix.""" + + async def test_mounted_tool_without_prefix_task_works( + self, parent_server_no_prefix + ): + """Mounted tool without prefix works as task.""" + async with Client(parent_server_no_prefix) as client: + # No prefix, so tool keeps original name + task = await client.call_tool("multiply", {"a": 5, "b": 6}, task=True) + + assert not task.returned_immediately + + result = await task.result() + assert result.data == 30 + + +class TestMountedPromptTasks: + """Test task execution for mounted prompts.""" + + async def test_mounted_prompt_task_returns_task_object(self, parent_server): + """Mounted prompt called with task=True returns a task object.""" + async with Client(parent_server) as client: + # Prompt name is prefixed: child_child_prompt + task = await client.get_prompt( + "child_child_prompt", {"topic": "FastMCP"}, task=True + ) + + assert task is not None + assert hasattr(task, "task_id") + assert isinstance(task.task_id, str) + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_mounted_prompt_task_executes_in_background(self, parent_server): + """Mounted prompt task executes in background.""" + async with Client(parent_server) as client: + task = await client.get_prompt( + "child_child_prompt", {"topic": "testing"}, task=True + ) + + assert not task.returned_immediately + + async def test_mounted_prompt_task_returns_correct_result( + self, parent_server: FastMCP + ): + """Mounted prompt task returns correct result.""" + async with Client(parent_server) as client: + task = await client.get_prompt( + "child_child_prompt", {"topic": "MCP protocol"}, task=True + ) + + result = await task.result() + assert "MCP protocol" in result.messages[0].content.text + assert "child server" in result.messages[0].content.text + + +class TestMountedResourceTasks: + """Test task execution for mounted resources.""" + + async def test_mounted_resource_task_returns_task_object(self, parent_server): + """Mounted resource read with task=True returns a task object.""" + async with Client(parent_server) as client: + # Resource URI is prefixed: child://child/data.txt + task = await client.read_resource("child://child/data.txt", task=True) + + assert task is not None + assert hasattr(task, "task_id") + assert isinstance(task.task_id, str) + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_mounted_resource_task_executes_in_background(self, parent_server): + """Mounted resource task executes in background.""" + async with Client(parent_server) as client: + task = await client.read_resource("child://child/data.txt", task=True) + + assert not task.returned_immediately + + async def test_mounted_resource_task_returns_correct_result(self, parent_server): + """Mounted resource task returns correct result.""" + async with Client(parent_server) as client: + task = await client.read_resource("child://child/data.txt", task=True) + + result = await task.result() + assert len(result) > 0 + assert "Data from child server" in result[0].text + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_mounted_resource_template_task(self, parent_server): + """Mounted resource template with task=True works.""" + async with Client(parent_server) as client: + task = await client.read_resource("child://child/item/99.json", task=True) + + assert not task.returned_immediately + + result = await task.result() + assert '"itemId": "99"' in result[0].text + assert '"source": "child"' in result[0].text + + +class TestMountedTaskDependencies: + """Test that dependencies work correctly in mounted task execution.""" + + async def test_mounted_task_receives_docket_dependency(self): + """Mounted tool task receives CurrentDocket dependency.""" + child = FastMCP("dep-child") + received_docket = [] + + @child.tool(task=True) + async def tool_with_docket(docket: CurrentDocket = CurrentDocket()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form] + received_docket.append(docket) + return f"docket available: {docket is not None}" + + parent = FastMCP("dep-parent") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + task = await client.call_tool("child_tool_with_docket", {}, task=True) + result = await task.result() + + assert "docket available: True" in str(result) + assert len(received_docket) == 1 + assert received_docket[0] is not None + + async def test_mounted_task_receives_server_dependency(self): + """Mounted tool task receives CurrentFastMCP dependency.""" + child = FastMCP("server-dep-child") + received_server = [] + + @child.tool(task=True) + async def tool_with_server(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form] + received_server.append(server) + return f"server name: {server.name}" + + parent = FastMCP("server-dep-parent") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + task = await client.call_tool("child_tool_with_server", {}, task=True) + await task.result() + + assert len(received_server) == 1 + assert received_server[0].name == "server-dep-child" + + +class TestMountedTaskServerContext: + """Test that background tasks on mounted servers resolve to the child server (#3571).""" + + async def test_current_fastmcp_resolves_to_child_server(self): + """CurrentFastMCP() inside a mounted background task returns the child server.""" + child = FastMCP("child") + received_server: list[FastMCP] = [] + + @child.tool(task=True) + async def whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form] + received_server.append(server) + return f"server name: {server.name}" + + parent = FastMCP("parent") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + task = await client.call_tool("child_whoami", {}, task=True) + result = await task.result() + + assert len(received_server) == 1 + assert received_server[0].name == "child" + assert "server name: child" in str(result) + + async def test_context_fastmcp_resolves_to_child_server(self): + """ctx.fastmcp inside a mounted background task returns the child server.""" + from fastmcp import Context + + child = FastMCP("child") + received_server: list[FastMCP] = [] + + @child.tool(task=True) + async def whoami_ctx(ctx: Context) -> str: + received_server.append(ctx.fastmcp) + return f"context server: {ctx.fastmcp.name}" + + parent = FastMCP("parent") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + task = await client.call_tool("child_whoami_ctx", {}, task=True) + result = await task.result() + + assert len(received_server) == 1 + assert received_server[0].name == "child" + assert "context server: child" in str(result) + + async def test_nested_mount_resolves_to_innermost_server(self): + """Doubly-nested mounts resolve to the innermost child server.""" + grandchild = FastMCP("grandchild") + received_server: list[FastMCP] = [] + + @grandchild.tool(task=True) + async def deep_whoami(server: CurrentFastMCP = CurrentFastMCP()) -> str: # type: ignore[invalid-type-form] # ty:ignore[invalid-type-form] + received_server.append(server) + return f"server name: {server.name}" + + child = FastMCP("child") + child.mount(grandchild, namespace="gc") + + parent = FastMCP("parent") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + task = await client.call_tool("child_gc_deep_whoami", {}, task=True) + result = await task.result() + + assert len(received_server) == 1 + assert received_server[0].name == "grandchild" + assert "server name: grandchild" in str(result) + + +class TestMultipleMounts: + """Test tasks with multiple mounted servers.""" + + async def test_tasks_work_with_multiple_mounts(self): + """Tasks work correctly with multiple mounted servers.""" + child1 = FastMCP("child1") + child2 = FastMCP("child2") + + @child1.tool(task=True) + async def add(a: int, b: int) -> int: + return a + b + + @child2.tool(task=True) + async def subtract(a: int, b: int) -> int: + return a - b + + parent = FastMCP("multi-parent") + parent.mount(child1, namespace="math1") + parent.mount(child2, namespace="math2") + + async with Client(parent) as client: + task1 = await client.call_tool("math1_add", {"a": 10, "b": 5}, task=True) + task2 = await client.call_tool( + "math2_subtract", {"a": 10, "b": 5}, task=True + ) + + result1 = await task1.result() + result2 = await task2.result() + + assert result1.data == 15 + assert result2.data == 5 + + +class TestMountedFunctionNameCollisions: + """Test task execution when mounted servers have identically-named functions.""" + + async def test_multiple_mounts_with_same_function_names(self): + """Two mounted servers with identically-named functions don't collide.""" + child1 = FastMCP("child1") + child2 = FastMCP("child2") + + @child1.tool(task=True) + async def process(value: int) -> int: + return value * 2 # Double + + @child2.tool(task=True) + async def process(value: int) -> int: # noqa: F811 + return value * 3 # Triple + + parent = FastMCP("parent") + parent.mount(child1, namespace="c1") + parent.mount(child2, namespace="c2") + + async with Client(parent) as client: + # Both should execute their own implementation + task1 = await client.call_tool("c1_process", {"value": 10}, task=True) + task2 = await client.call_tool("c2_process", {"value": 10}, task=True) + + result1 = await task1.result() + result2 = await task2.result() + + assert result1.data == 20 # child1's process (doubles) + assert result2.data == 30 # child2's process (triples) + + async def test_no_prefix_mount_collision(self): + """No-prefix mounts with same tool name - last mount wins.""" + child1 = FastMCP("child1") + child2 = FastMCP("child2") + + @child1.tool(task=True) + async def process(value: int) -> int: + return value * 2 + + @child2.tool(task=True) + async def process(value: int) -> int: # noqa: F811 + return value * 3 + + parent = FastMCP("parent") + parent.mount(child1) # No prefix + parent.mount(child2) # No prefix - overwrites child1's "process" + + async with Client(parent) as client: + # Last mount wins - child2's process should execute + task = await client.call_tool("process", {"value": 10}, task=True) + result = await task.result() + assert result.data == 30 # child2's process (triples) + + async def test_nested_mount_prefix_accumulation(self): + """Nested mounts accumulate prefixes correctly for tasks.""" + grandchild = FastMCP("gc") + child = FastMCP("child") + parent = FastMCP("parent") + + @grandchild.tool(task=True) + async def deep_tool() -> str: + return "deep" + + child.mount(grandchild, namespace="gc") + parent.mount(child, namespace="child") + + async with Client(parent) as client: + # Tool should be accessible and execute correctly + task = await client.call_tool("child_gc_deep_tool", {}, task=True) + result = await task.result() + assert result.data == "deep" + + +class TestMountedTaskList: + """Test task listing with mounted servers.""" + + async def test_list_tasks_includes_mounted_tasks(self, parent_server): + """Task list includes tasks from mounted server tools.""" + async with Client(parent_server) as client: + # Create tasks on both parent and mounted tools + parent_task = await client.call_tool("parent_tool", {"value": 1}, task=True) + child_task = await client.call_tool( + "child_multiply", {"a": 2, "b": 2}, task=True + ) + + # Wait for completion + await parent_task.wait(timeout=2.0) + await child_task.wait(timeout=2.0) + + # List all tasks - returns dict with "tasks" key + tasks_response = await client.list_tasks() + + task_ids = [t["taskId"] for t in tasks_response["tasks"]] + assert parent_task.task_id in task_ids + assert child_task.task_id in task_ids + + +class TestMountedTaskMetadata: + """Test task metadata exposure for mounted tools.""" + + async def test_mounted_tool_list_preserves_task_support_metadata(self): + """Mounted tools should preserve execution.task_support in tools/list.""" + child = FastMCP("child") + + @child.tool(task=True) + async def foo() -> dict[str, bool]: + return {"ok": True} + + parent = FastMCP("parent") + parent.mount(child) + + child_tools = await child.list_tools() + parent_tools = await parent.list_tools() + + child_tool = next(t for t in child_tools if t.name == "foo") + parent_tool = next(t for t in parent_tools if t.name == "foo") + + child_mcp_tool = child_tool.to_mcp_tool(name=child_tool.name) + parent_mcp_tool = parent_tool.to_mcp_tool(name=parent_tool.name) + + assert child_mcp_tool.execution is not None + assert parent_mcp_tool.execution is not None + assert child_mcp_tool.execution.task_support == "optional" + assert parent_mcp_tool.execution.task_support == "optional" + + async def test_proxy_tool_preserves_execution_metadata(self): + """ProxyTool.from_mcp_tool should propagate execution.task_support (#3569).""" + mcp_tool = MCPTool( + name="remote_task_tool", + description="A remote tool that supports tasks", + input_schema={"type": "object", "properties": {}}, + execution=ToolExecution(task_support="optional"), + ) + + proxy = ProxyTool.from_mcp_tool(lambda: None, mcp_tool) # ty: ignore[invalid-argument-type] + result = proxy.to_mcp_tool(name=proxy.name) + + assert result.execution is not None + assert result.execution.task_support == "optional" + + +class TestMountedTaskConfigModes: + """Test TaskConfig mode enforcement for mounted tools.""" + + @pytest.fixture + def child_with_modes(self): + """Create a child server with tools in all three TaskConfig modes.""" + mcp = FastMCP("child-modes", tasks=False) + + @mcp.tool(task=TaskConfig(mode="optional")) + async def optional_tool() -> str: + """Tool that supports both sync and task execution.""" + return "optional result" + + @mcp.tool(task=TaskConfig(mode="required")) + async def required_tool() -> str: + """Tool that requires task execution.""" + return "required result" + + @mcp.tool(task=TaskConfig(mode="forbidden")) + async def forbidden_tool() -> str: + """Tool that forbids task execution.""" + return "forbidden result" + + return mcp + + @pytest.fixture + def parent_with_modes(self, child_with_modes): + """Create a parent server with the child mounted.""" + parent = FastMCP("parent-modes") + parent.mount(child_with_modes, namespace="child") + return parent + + async def test_optional_mode_sync_through_mount(self, parent_with_modes): + """Optional mode tool works without task through mount.""" + async with Client(parent_with_modes) as client: + result = await client.call_tool("child_optional_tool", {}) + assert "optional result" in str(result) + + async def test_optional_mode_task_through_mount(self, parent_with_modes): + """Optional mode tool works with task through mount.""" + async with Client(parent_with_modes) as client: + task = await client.call_tool("child_optional_tool", {}, task=True) + assert task is not None + result = await task.result() + assert result.data == "optional result" + + async def test_required_mode_with_task_through_mount(self, parent_with_modes): + """Required mode tool succeeds with task through mount.""" + async with Client(parent_with_modes) as client: + task = await client.call_tool("child_required_tool", {}, task=True) + assert task is not None + result = await task.result() + assert result.data == "required result" + + async def test_required_mode_without_task_through_mount(self, parent_with_modes): + """Required mode tool errors without task through mount.""" + from fastmcp.exceptions import ToolError + + async with Client(parent_with_modes) as client: + with pytest.raises(ToolError) as exc_info: + await client.call_tool("child_required_tool", {}) + + assert "requires task-augmented execution" in str(exc_info.value) + + async def test_forbidden_mode_sync_through_mount(self, parent_with_modes): + """Forbidden mode tool works without task through mount.""" + async with Client(parent_with_modes) as client: + result = await client.call_tool("child_forbidden_tool", {}) + assert "forbidden result" in str(result) + + async def test_forbidden_mode_with_task_through_mount(self, parent_with_modes): + """Forbidden mode tool degrades gracefully with task through mount.""" + async with Client(parent_with_modes) as client: + task = await client.call_tool( + "child_forbidden_tool", {}, task=True, raise_on_error=False + ) + + # Should return immediately (graceful degradation) + assert task.returned_immediately + + result = await task.result() + # Result is available but may indicate error or sync execution + assert result is not None + + +# ----------------------------------------------------------------------------- +# Middleware classes for tracing tests +# ----------------------------------------------------------------------------- + + +class ToolTracingMiddleware(Middleware): + """Middleware that traces tool calls.""" + + def __init__(self, name: str, calls: list[str]): + super().__init__() + self._name = name + self._calls = calls + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, ToolResult], + ) -> ToolResult: + self._calls.append(f"{self._name}:before") + result = await call_next(context) + self._calls.append(f"{self._name}:after") + return result + + +class ResourceTracingMiddleware(Middleware): + """Middleware that traces resource reads.""" + + def __init__(self, name: str, calls: list[str]): + super().__init__() + self._name = name + self._calls = calls + + async def on_read_resource( + self, + context: MiddlewareContext[mt.ReadResourceRequestParams], + call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult], + ) -> ResourceResult: + self._calls.append(f"{self._name}:before") + result = await call_next(context) + self._calls.append(f"{self._name}:after") + return result + + +class PromptTracingMiddleware(Middleware): + """Middleware that traces prompt gets.""" + + def __init__(self, name: str, calls: list[str]): + super().__init__() + self._name = name + self._calls = calls + + async def on_get_prompt( + self, + context: MiddlewareContext[mt.GetPromptRequestParams], + call_next: CallNext[mt.GetPromptRequestParams, PromptResult], + ) -> PromptResult: + self._calls.append(f"{self._name}:before") + result = await call_next(context) + self._calls.append(f"{self._name}:after") + return result + + +class TestMiddlewareWithMountedTasks: + """Test that middleware runs at all levels when executing background tasks. + + For background tasks, middleware runs during task submission (wrapping the MCP + request handling that queues to Docket). The actual function execution happens + later in the Docket worker, after the middleware chain completes. + """ + + async def test_tool_middleware_runs_with_background_task(self): + """Middleware runs at parent, child, and grandchild levels for tool tasks.""" + calls: list[str] = [] + + grandchild = FastMCP("Grandchild") + + @grandchild.tool(task=True) + async def compute(x: int) -> int: + calls.append("grandchild:tool") + return x * 2 + + grandchild.add_middleware(ToolTracingMiddleware("grandchild", calls)) + + child = FastMCP("Child") + child.mount(grandchild, namespace="gc") + child.add_middleware(ToolTracingMiddleware("child", calls)) + + parent = FastMCP("Parent") + parent.mount(child, namespace="c") + parent.add_middleware(ToolTracingMiddleware("parent", calls)) + + async with Client(parent) as client: + task = await client.call_tool("c_gc_compute", {"x": 5}, task=True) + result = await task.result() + assert result.data == 10 + + # Middleware runs during task submission (before/after queuing to Docket) + # Function executes later in Docket worker + assert calls == [ + "parent:before", + "child:before", + "grandchild:before", + "grandchild:after", + "child:after", + "parent:after", + "grandchild:tool", # Executes in Docket after middleware completes + ] + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_resource_middleware_runs_with_background_task(self): + """Middleware runs at parent, child, and grandchild levels for resource tasks.""" + calls: list[str] = [] + + grandchild = FastMCP("Grandchild") + + @grandchild.resource("data://value", task=True) + async def get_data() -> str: + calls.append("grandchild:resource") + return "result" + + grandchild.add_middleware(ResourceTracingMiddleware("grandchild", calls)) + + child = FastMCP("Child") + child.mount(grandchild, namespace="gc") + child.add_middleware(ResourceTracingMiddleware("child", calls)) + + parent = FastMCP("Parent") + parent.mount(child, namespace="c") + parent.add_middleware(ResourceTracingMiddleware("parent", calls)) + + async with Client(parent) as client: + task = await client.read_resource("data://c/gc/value", task=True) + result = await task.result() + assert result[0].text == "result" + + # Middleware runs during task submission, function in Docket + assert calls == [ + "parent:before", + "child:before", + "grandchild:before", + "grandchild:after", + "child:after", + "parent:after", + "grandchild:resource", + ] + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_prompt_middleware_runs_with_background_task(self): + """Middleware runs at parent, child, and grandchild levels for prompt tasks.""" + calls: list[str] = [] + + grandchild = FastMCP("Grandchild") + + @grandchild.prompt(task=True) + async def greet(name: str) -> str: + calls.append("grandchild:prompt") + return f"Hello, {name}!" + + grandchild.add_middleware(PromptTracingMiddleware("grandchild", calls)) + + child = FastMCP("Child") + child.mount(grandchild, namespace="gc") + child.add_middleware(PromptTracingMiddleware("child", calls)) + + parent = FastMCP("Parent") + parent.mount(child, namespace="c") + parent.add_middleware(PromptTracingMiddleware("parent", calls)) + + async with Client(parent) as client: + task = await client.get_prompt("c_gc_greet", {"name": "World"}, task=True) + result = await task.result() + assert result.messages[0].content.text == "Hello, World!" + + # Middleware runs during task submission, function in Docket + assert calls == [ + "parent:before", + "child:before", + "grandchild:before", + "grandchild:after", + "child:after", + "parent:after", + "grandchild:prompt", + ] + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_resource_template_middleware_runs_with_background_task(self): + """Middleware runs at all levels for resource template tasks.""" + calls: list[str] = [] + + grandchild = FastMCP("Grandchild") + + @grandchild.resource("item://{id}", task=True) + async def get_item(id: str) -> str: + calls.append("grandchild:template") + return f"item-{id}" + + grandchild.add_middleware(ResourceTracingMiddleware("grandchild", calls)) + + child = FastMCP("Child") + child.mount(grandchild, namespace="gc") + child.add_middleware(ResourceTracingMiddleware("child", calls)) + + parent = FastMCP("Parent") + parent.mount(child, namespace="c") + parent.add_middleware(ResourceTracingMiddleware("parent", calls)) + + async with Client(parent) as client: + task = await client.read_resource("item://c/gc/42", task=True) + result = await task.result() + assert result[0].text == "item-42" + + # Middleware runs during task submission, function in Docket + assert calls == [ + "parent:before", + "child:before", + "grandchild:before", + "grandchild:after", + "child:after", + "parent:after", + "grandchild:template", + ] + + +class TestMountedTasksWithTaskMetaParameter: + """Test mounted components called directly with task_meta parameter. + + These tests verify the programmatic API where server.call_tool() or + server.read_resource() is called with an explicit task_meta parameter, + as opposed to using the Client with task=True. + + Direct server calls require a running server context, so we use an outer + tool that makes the direct call internally. + """ + + async def test_mounted_tool_with_task_meta_creates_task(self): + """Mounted tool called with task_meta returns CreateTaskResult.""" + from fastmcp.server.tasks.config import TaskMeta + + child = FastMCP("Child") + + @child.tool(task=True) + async def add(a: int, b: int) -> int: + return a + b + + parent = FastMCP("Parent") + parent.mount(child, namespace="child") + + @parent.tool + async def outer() -> str: + # Direct call with task_meta from within server context + result = await parent.call_tool( + "child_add", {"a": 2, "b": 3}, task_meta=TaskMeta(ttl=300) + ) + return f"task:{result.task.task_id}" + + async with Client(parent) as client: + result = await client.call_tool("outer", {}) + assert "task:" in str(result) + + async def test_mounted_resource_with_task_meta_creates_task(self): + """Mounted resource called with task_meta returns CreateTaskResult.""" + from fastmcp.server.tasks.config import TaskMeta + + child = FastMCP("Child") + + @child.resource("data://info", task=True) + async def get_info() -> str: + return "child info" + + parent = FastMCP("Parent") + parent.mount(child, namespace="child") + + @parent.tool + async def outer() -> str: + result = await parent.read_resource( + "data://child/info", task_meta=TaskMeta(ttl=300) + ) + return f"task:{result.task.task_id}" + + async with Client(parent) as client: + result = await client.call_tool("outer", {}) + assert "task:" in str(result) + + async def test_mounted_template_with_task_meta_creates_task(self): + """Mounted resource template with task_meta returns CreateTaskResult.""" + from fastmcp.server.tasks.config import TaskMeta + + child = FastMCP("Child") + + @child.resource("item://{id}", task=True) + async def get_item(id: str) -> str: + return f"item-{id}" + + parent = FastMCP("Parent") + parent.mount(child, namespace="child") + + @parent.tool + async def outer() -> str: + result = await parent.read_resource( + "item://child/42", task_meta=TaskMeta(ttl=300) + ) + return f"task:{result.task.task_id}" + + async with Client(parent) as client: + result = await client.call_tool("outer", {}) + assert "task:" in str(result) + + async def test_deeply_nested_tool_with_task_meta(self): + """Three-level nested tool works with task_meta.""" + from fastmcp.server.tasks.config import TaskMeta + + grandchild = FastMCP("Grandchild") + + @grandchild.tool(task=True) + async def compute(n: int) -> int: + return n * 3 + + child = FastMCP("Child") + child.mount(grandchild, namespace="gc") + + parent = FastMCP("Parent") + parent.mount(child, namespace="c") + + @parent.tool + async def outer() -> str: + result = await parent.call_tool( + "c_gc_compute", {"n": 7}, task_meta=TaskMeta(ttl=300) + ) + return f"task:{result.task.task_id}" + + async with Client(parent) as client: + result = await client.call_tool("outer", {}) + assert "task:" in str(result) + + async def test_deeply_nested_template_with_task_meta(self): + """Three-level nested template works with task_meta.""" + from fastmcp.server.tasks.config import TaskMeta + + grandchild = FastMCP("Grandchild") + + @grandchild.resource("doc://{name}", task=True) + async def get_doc(name: str) -> str: + return f"doc: {name}" + + child = FastMCP("Child") + child.mount(grandchild, namespace="gc") + + parent = FastMCP("Parent") + parent.mount(child, namespace="c") + + @parent.tool + async def outer() -> str: + result = await parent.read_resource( + "doc://c/gc/readme", task_meta=TaskMeta(ttl=300) + ) + return f"task:{result.task.task_id}" + + async with Client(parent) as client: + result = await client.call_tool("outer", {}) + assert "task:" in str(result) + + async def test_mounted_prompt_with_task_meta_creates_task(self): + """Mounted prompt called with task_meta returns CreateTaskResult.""" + from fastmcp.server.tasks.config import TaskMeta + + child = FastMCP("Child") + + @child.prompt(task=True) + async def greet(name: str) -> str: + return f"Hello, {name}!" + + parent = FastMCP("Parent") + parent.mount(child, namespace="child") + + @parent.tool + async def outer() -> str: + result = await parent.render_prompt( + "child_greet", {"name": "World"}, task_meta=TaskMeta(ttl=300) + ) + return f"task:{result.task.task_id}" + + async with Client(parent) as client: + result = await client.call_tool("outer", {}) + assert "task:" in str(result) + + async def test_deeply_nested_prompt_with_task_meta(self): + """Three-level nested prompt works with task_meta.""" + from fastmcp.server.tasks.config import TaskMeta + + grandchild = FastMCP("Grandchild") + + @grandchild.prompt(task=True) + async def describe(topic: str) -> str: + return f"Information about {topic}" + + child = FastMCP("Child") + child.mount(grandchild, namespace="gc") + + parent = FastMCP("Parent") + parent.mount(child, namespace="c") + + @parent.tool + async def outer() -> str: + result = await parent.render_prompt( + "c_gc_describe", {"topic": "FastMCP"}, task_meta=TaskMeta(ttl=300) + ) + return f"task:{result.task.task_id}" + + async with Client(parent) as client: + result = await client.call_tool("outer", {}) + assert "task:" in str(result) diff --git a/tests/server/tasks/test_task_prompts.py b/tests/server/tasks/test_task_prompts.py new file mode 100644 index 000000000..ca62a3a0f --- /dev/null +++ b/tests/server/tasks/test_task_prompts.py @@ -0,0 +1,103 @@ +""" +Tests for SEP-1686 background task support for prompts. + +Tests that prompts with task=True can execute in background. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.tasks import PromptTask + + +@pytest.fixture +async def prompt_server(): + """Create a FastMCP server with task-enabled prompts.""" + mcp = FastMCP("prompt-test-server") + + @mcp.prompt() + async def simple_prompt(topic: str) -> str: + """A simple prompt template.""" + return f"Write about: {topic}" + + @mcp.prompt(task=True) + async def background_prompt(topic: str, depth: str = "detailed") -> str: + """A prompt that can execute in background.""" + return f"Write a {depth} analysis of: {topic}" + + return mcp + + +async def test_synchronous_prompt_unchanged(prompt_server): + """Prompts without task metadata execute synchronously as before.""" + async with Client(prompt_server) as client: + # Regular call without task metadata + result = await client.get_prompt("simple_prompt", {"topic": "AI"}) + + # Should execute immediately and return result + assert "Write about: AI" in str(result) + + +async def test_prompt_with_task_metadata_returns_immediately(prompt_server): + """Prompts with task metadata return immediately with PromptTask object.""" + async with Client(prompt_server) as client: + # Call with task metadata + task = await client.get_prompt("background_prompt", {"topic": "AI"}, task=True) + + # Should return a PromptTask object immediately + assert isinstance(task, PromptTask) + assert isinstance(task.task_id, str) + assert len(task.task_id) > 0 + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_prompt_task_executes_in_background(prompt_server): + """Prompt task executes via Docket in background.""" + async with Client(prompt_server) as client: + task = await client.get_prompt( + "background_prompt", + {"topic": "Machine Learning", "depth": "comprehensive"}, + task=True, + ) + + # Verify background execution + assert not task.returned_immediately + + # Get the result + result = await task.result() + assert "comprehensive" in result.messages[0].content.text.lower() + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_forbidden_mode_prompt_rejects_task_calls(prompt_server): + """Prompts with task=False (mode=forbidden) reject task-augmented calls.""" + from mcp.shared.exceptions import MCPError + from mcp_types import METHOD_NOT_FOUND + + @prompt_server.prompt(task=False) # Explicitly disable task support + async def sync_only_prompt(topic: str) -> str: + return f"Sync prompt: {topic}" + + async with Client(prompt_server) as client: + # Calling with task=True when task=False should raise MCPError + import pytest + + with pytest.raises(MCPError) as exc_info: + await client.get_prompt("sync_only_prompt", {"topic": "test"}, task=True) + + # New behavior: mode="forbidden" returns METHOD_NOT_FOUND error + assert exc_info.value.error.code == METHOD_NOT_FOUND + assert ( + "does not support task-augmented execution" in exc_info.value.error.message + ) diff --git a/tests/server/tasks/test_task_protocol.py b/tests/server/tasks/test_task_protocol.py new file mode 100644 index 000000000..9fd2fd7a6 --- /dev/null +++ b/tests/server/tasks/test_task_protocol.py @@ -0,0 +1,81 @@ +""" +Tests for SEP-1686 protocol-level task handling. + +Generic protocol tests that use tools as test fixtures. +Tests metadata, notifications, and error handling at the protocol level. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client + + +@pytest.fixture +async def task_enabled_server(): + """Create a FastMCP server with task-enabled tools.""" + mcp = FastMCP("task-test-server") + + @mcp.tool(task=True) + async def simple_tool(message: str) -> str: + """A simple tool for testing.""" + return f"Processed: {message}" + + @mcp.tool(task=True) + async def failing_tool() -> str: + """A tool that always fails.""" + raise ValueError("This tool always fails") + + return mcp + + +async def test_task_metadata_includes_task_id_and_ttl(task_enabled_server): + """Task metadata properly includes server-generated taskId and ttl.""" + async with Client(task_enabled_server) as client: + # Submit with specific ttl (server generates task ID) + task = await client.call_tool( + "simple_tool", + {"message": "test"}, + task=True, + ttl=30000, + ) + assert task + assert not task.returned_immediately + + # Server should have generated a task ID + assert task.task_id is not None + assert isinstance(task.task_id, str) + + +async def test_task_notification_sent_after_submission(task_enabled_server): + """Server sends an initial task status notification after submission.""" + + @task_enabled_server.tool(task=True) + async def background_tool(message: str) -> str: + return f"Processed: {message}" + + async with Client(task_enabled_server) as client: + task = await client.call_tool("background_tool", {"message": "test"}, task=True) + assert task + assert not task.returned_immediately + + # Verify we can query the task + status = await task.status() + assert status.task_id == task.task_id + + +async def test_failed_task_stores_error(task_enabled_server): + """Failed tasks store the error in results.""" + + @task_enabled_server.tool(task=True) + async def failing_task_tool() -> str: + raise ValueError("This tool always fails") + + async with Client(task_enabled_server) as client: + task = await client.call_tool("failing_task_tool", task=True) + assert task + assert not task.returned_immediately + + # Wait for task to fail + status = await task.wait(state="failed", timeout=2.0) + assert status.status == "failed" diff --git a/tests/server/tasks/test_task_proxy.py b/tests/server/tasks/test_task_proxy.py new file mode 100644 index 000000000..c272a4444 --- /dev/null +++ b/tests/server/tasks/test_task_proxy.py @@ -0,0 +1,192 @@ +""" +Tests for MCP SEP-1686 task protocol behavior through proxy servers. + +Proxy servers explicitly forbid task-augmented execution. All proxy components +(tools, prompts, resources) have task_config.mode="forbidden". + +Clients connecting through proxies can: +- Execute tools/prompts/resources normally (sync execution) +- NOT use task-augmented execution (task=True fails gracefully for tools, + raises MCPError for prompts/resources) +""" + +import pytest +from mcp.shared.exceptions import MCPError +from mcp_types import TextContent, TextResourceContents + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.transports import FastMCPTransport +from fastmcp.server import create_proxy + + +@pytest.fixture +def backend_server() -> FastMCP: + """Create a backend server with task-enabled components. + + The backend has tasks enabled, but the proxy should NOT forward + task execution - it should treat all components as forbidden. + """ + mcp = FastMCP("backend-server") + + @mcp.tool(task=True) + async def add_numbers(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + @mcp.tool(task=False) + async def sync_only_tool(message: str) -> str: + """Tool that only supports synchronous execution.""" + return f"sync: {message}" + + @mcp.prompt(task=True) + async def greeting_prompt(name: str) -> str: + """A prompt that can execute as a task.""" + return f"Hello, {name}! Welcome to the system." + + @mcp.resource("data://info.txt", task=True) + async def info_resource() -> str: + """A resource that can be read as a task.""" + return "Important information from the backend" + + @mcp.resource("data://user/{user_id}.json", task=True) + async def user_resource(user_id: str) -> str: + """A resource template that can execute as a task.""" + return f'{{"id": "{user_id}", "name": "User {user_id}"}}' + + return mcp + + +@pytest.fixture +def proxy_server(backend_server: FastMCP) -> FastMCP: + """Create a proxy server that forwards to the backend.""" + return create_proxy(FastMCPTransport(backend_server)) + + +class TestProxyToolsSyncExecution: + """Test that tools work normally through proxy (sync execution).""" + + async def test_tool_sync_execution_works(self, proxy_server: FastMCP): + """Tool called without task=True works through proxy.""" + async with Client(proxy_server) as client: + result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) + assert "8" in str(result) + + async def test_sync_only_tool_works(self, proxy_server: FastMCP): + """Sync-only tool works through proxy.""" + async with Client(proxy_server) as client: + result = await client.call_tool("sync_only_tool", {"message": "test"}) + assert "sync: test" in str(result) + + +class TestProxyToolsTaskForbidden: + """Test that tools with task=True are forbidden through proxy.""" + + async def test_tool_task_returns_error_immediately(self, proxy_server: FastMCP): + """Tool called with task=True through proxy returns error immediately.""" + async with Client(proxy_server) as client: + task = await client.call_tool( + "add_numbers", {"a": 5, "b": 3}, task=True, raise_on_error=False + ) + + # Should return immediately (forbidden behavior) + assert task.returned_immediately + + # Result should be an error + result = await task.result() + assert result.is_error + + async def test_sync_only_tool_task_returns_error_immediately( + self, proxy_server: FastMCP + ): + """Sync-only tool with task=True also returns error immediately.""" + async with Client(proxy_server) as client: + task = await client.call_tool( + "sync_only_tool", + {"message": "test"}, + task=True, + raise_on_error=False, + ) + + assert task.returned_immediately + result = await task.result() + assert result.is_error + + +class TestProxyPromptsSyncExecution: + """Test that prompts work normally through proxy (sync execution).""" + + async def test_prompt_sync_execution_works(self, proxy_server: FastMCP): + """Prompt called without task=True works through proxy.""" + async with Client(proxy_server) as client: + result = await client.get_prompt("greeting_prompt", {"name": "Alice"}) + assert isinstance(result.messages[0].content, TextContent) + assert "Hello, Alice!" in result.messages[0].content.text + + +class TestProxyPromptsTaskForbidden: + """Test that prompts with task=True are forbidden through proxy.""" + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_prompt_task_raises_mcp_error(self, proxy_server: FastMCP): + """Prompt called with task=True through proxy raises MCPError.""" + async with Client(proxy_server) as client: + with pytest.raises(MCPError) as exc_info: + await client.get_prompt("greeting_prompt", {"name": "Alice"}, task=True) + + assert "does not support task-augmented execution" in str(exc_info.value) + + +class TestProxyResourcesSyncExecution: + """Test that resources work normally through proxy (sync execution).""" + + async def test_resource_sync_execution_works(self, proxy_server: FastMCP): + """Resource read without task=True works through proxy.""" + async with Client(proxy_server) as client: + result = await client.read_resource("data://info.txt") + assert isinstance(result[0], TextResourceContents) + assert "Important information from the backend" in result[0].text + + async def test_resource_template_sync_execution_works(self, proxy_server: FastMCP): + """Resource template without task=True works through proxy.""" + async with Client(proxy_server) as client: + result = await client.read_resource("data://user/42.json") + assert isinstance(result[0], TextResourceContents) + assert '"id": "42"' in result[0].text + + +class TestProxyResourcesTaskForbidden: + """Test that resources with task=True are forbidden through proxy.""" + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_resource_task_raises_mcp_error(self, proxy_server: FastMCP): + """Resource read with task=True through proxy raises MCPError.""" + async with Client(proxy_server) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("data://info.txt", task=True) + + assert "does not support task-augmented execution" in str(exc_info.value) + + @pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, + ) + async def test_resource_template_task_raises_mcp_error(self, proxy_server: FastMCP): + """Resource template with task=True through proxy raises MCPError.""" + async with Client(proxy_server) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("data://user/42.json", task=True) + + assert "does not support task-augmented execution" in str(exc_info.value) diff --git a/tests/server/tasks/test_task_resources.py b/tests/server/tasks/test_task_resources.py new file mode 100644 index 000000000..f7768adc7 --- /dev/null +++ b/tests/server/tasks/test_task_resources.py @@ -0,0 +1,125 @@ +""" +Tests for SEP-1686 background task support for resources. + +Tests that resources with task=True can execute in background. +""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.tasks import ResourceTask + + +@pytest.fixture +async def resource_server(): + """Create a FastMCP server with task-enabled resources.""" + mcp = FastMCP("resource-test-server") + + @mcp.resource("file://data.txt") + async def simple_resource() -> str: + """A simple resource.""" + return "Simple content" + + @mcp.resource("file://large.txt", task=True) + async def background_resource() -> str: + """A resource that can execute in background.""" + return "Large file content that takes time to load" + + @mcp.resource("file://user/{user_id}/data.json", task=True) + async def template_resource(user_id: str) -> str: + """A resource template that can execute in background.""" + return f'{{"userId": "{user_id}", "data": "value"}}' + + return mcp + + +async def test_synchronous_resource_unchanged(resource_server): + """Resources without task metadata execute synchronously as before.""" + async with Client(resource_server) as client: + # Regular call without task metadata + result = await client.read_resource("file://data.txt") + + # Should execute immediately and return result + assert "Simple content" in str(result) + + +async def test_resource_with_task_metadata_returns_immediately(resource_server): + """Resources with task metadata return immediately with ResourceTask object.""" + async with Client(resource_server) as client: + # Call with task metadata + task = await client.read_resource("file://large.txt", task=True) + + # Should return a ResourceTask object immediately + assert isinstance(task, ResourceTask) + assert isinstance(task.task_id, str) + assert len(task.task_id) > 0 + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_resource_task_executes_in_background(resource_server): + """Resource task executes via Docket in background.""" + async with Client(resource_server) as client: + task = await client.read_resource("file://large.txt", task=True) + + # Verify background execution + assert not task.returned_immediately + + # Get the result + result = await task.result() + assert len(result) > 0 + assert result[0].text == "Large file content that takes time to load" + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_resource_template_with_task(resource_server): + """Resource templates with task=True execute in background.""" + async with Client(resource_server) as client: + task = await client.read_resource("file://user/123/data.json", task=True) + + # Verify background execution + assert not task.returned_immediately + + # Get the result + result = await task.result() + assert '"userId": "123"' in result[0].text + + +@pytest.mark.xfail( + reason="SDK v2 has no `task` field on GetPromptRequestParams / " + "ReadResourceRequestParams; prompt/resource task submission is not " + "wire-expressible and always graceful-degrades (sdk-feedback #3).", + strict=True, +) +async def test_forbidden_mode_resource_rejects_task_calls(resource_server): + """Resources with task=False (mode=forbidden) reject task-augmented calls.""" + import pytest + from mcp.shared.exceptions import MCPError + from mcp_types import METHOD_NOT_FOUND + + @resource_server.resource( + "file://sync.txt/", task=False + ) # Explicitly disable task support + async def sync_only_resource() -> str: + return "Sync content" + + async with Client(resource_server) as client: + # Calling with task=True when task=False should raise MCPError + with pytest.raises(MCPError) as exc_info: + await client.read_resource("file://sync.txt", task=True) + + # New behavior: mode="forbidden" returns METHOD_NOT_FOUND error + assert exc_info.value.error.code == METHOD_NOT_FOUND + assert ( + "does not support task-augmented execution" in exc_info.value.error.message + ) diff --git a/tests/server/tasks/test_task_return_types.py b/tests/server/tasks/test_task_return_types.py new file mode 100644 index 000000000..15452c79f --- /dev/null +++ b/tests/server/tasks/test_task_return_types.py @@ -0,0 +1,669 @@ +""" +Tests to verify all return types work identically with task=True. + +These tests ensure that enabling background task support doesn't break +existing functionality - any tool/prompt/resource should work exactly +the same whether task=True or task=False. +""" + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any +from uuid import UUID + +import pytest +from pydantic import BaseModel +from typing_extensions import TypedDict + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.utilities.types import Audio, File, Image + + +class UserData(BaseModel): + """Example structured output.""" + + name: str + age: int + active: bool + + +@pytest.fixture +async def return_type_server(): + """Server with tools that return various types.""" + mcp = FastMCP("return-type-test") + + # String return + @mcp.tool(task=True) + async def return_string() -> str: + return "Hello, World!" + + # Integer return + @mcp.tool(task=True) + async def return_int() -> int: + return 42 + + # Float return + @mcp.tool(task=True) + async def return_float() -> float: + return 3.14159 + + # Boolean return + @mcp.tool(task=True) + async def return_bool() -> bool: + return True + + # Dict return + @mcp.tool(task=True) + async def return_dict() -> dict[str, int]: + return {"count": 100, "total": 500} + + # List return + @mcp.tool(task=True) + async def return_list() -> list[str]: + return ["apple", "banana", "cherry"] + + # BaseModel return (structured output) + @mcp.tool(task=True) + async def return_model() -> UserData: + return UserData(name="Alice", age=30, active=True) + + # None/null return + @mcp.tool(task=True) + async def return_none() -> None: + return None + + return mcp + + +@pytest.mark.parametrize( + "tool_name,expected_type,expected_value", + [ + ("return_string", str, "Hello, World!"), + ("return_int", int, 42), + ("return_float", float, 3.14159), + ("return_bool", bool, True), + ("return_dict", dict, {"count": 100, "total": 500}), + ("return_list", list, ["apple", "banana", "cherry"]), + ("return_none", type(None), None), + ], +) +async def test_task_basic_types( + return_type_server: FastMCP, + tool_name: str, + expected_type: type, + expected_value: Any, +): + """Task mode returns basic types correctly.""" + async with Client(return_type_server) as client: + task = await client.call_tool(tool_name, task=True) + result = await task + assert isinstance(result.data, expected_type) + assert result.data == expected_value + + +async def test_task_model_return(return_type_server): + """Task mode returns same BaseModel (as dict) as immediate mode.""" + async with Client(return_type_server) as client: + task = await client.call_tool("return_model", task=True) + result = await task + + # Client deserializes to dynamic class (type name lost with title pruning) + assert result.data.__class__.__name__ == "Root" + assert result.data.name == "Alice" + assert result.data.age == 30 + assert result.data.active is True + + +async def test_task_vs_immediate_equivalence(return_type_server): + """Verify task mode and immediate mode return identical results.""" + async with Client(return_type_server) as client: + # Test a few types to verify equivalence + tools_to_test = ["return_string", "return_int", "return_dict"] + + for tool_name in tools_to_test: + # Call as task + task = await client.call_tool(tool_name, task=True) + task_result = await task + + # Call immediately (server should decline background execution when no task meta) + immediate_result = await client.call_tool(tool_name) + + # Results should be identical + assert task_result.data == immediate_result.data, ( + f"Mismatch for {tool_name}" + ) + + +@pytest.fixture +async def prompt_return_server(): + """Server with prompts that return various message structures.""" + mcp = FastMCP("prompt-return-test") + + @mcp.prompt(task=True) + async def single_message_prompt() -> str: + """Return a single string message.""" + return "Single message content" + + @mcp.prompt(task=True) + async def multi_message_prompt() -> list[str]: + """Return multiple messages.""" + return [ + "First message", + "Second message", + "Third message", + ] + + return mcp + + +async def test_prompt_task_single_message(prompt_return_server): + """Prompt task returns single message correctly.""" + async with Client(prompt_return_server) as client: + task = await client.get_prompt("single_message_prompt", task=True) + result = await task + + assert len(result.messages) == 1 + assert result.messages[0].content.text == "Single message content" + + +async def test_prompt_task_multiple_messages(prompt_return_server): + """Prompt task returns multiple messages correctly.""" + async with Client(prompt_return_server) as client: + task = await client.get_prompt("multi_message_prompt", task=True) + result = await task + + assert len(result.messages) == 3 + assert result.messages[0].content.text == "First message" + assert result.messages[1].content.text == "Second message" + assert result.messages[2].content.text == "Third message" + + +@pytest.fixture +async def resource_return_server(): + """Server with resources that return various content types.""" + mcp = FastMCP("resource-return-test") + + @mcp.resource("text://simple", task=True) + async def simple_text() -> str: + """Return simple text content.""" + return "Simple text resource" + + @mcp.resource("data://json", task=True) + async def json_data() -> str: + """Return JSON-like data.""" + import json + + return json.dumps({"key": "value", "count": 123}) + + return mcp + + +async def test_resource_task_text_content(resource_return_server): + """Resource task returns text content correctly.""" + async with Client(resource_return_server) as client: + task = await client.read_resource("text://simple", task=True) + contents = await task + + assert len(contents) == 1 + assert contents[0].text == "Simple text resource" + + +async def test_resource_task_json_content(resource_return_server): + """Resource task returns structured content correctly.""" + async with Client(resource_return_server) as client: + task = await client.read_resource("data://json", task=True) + contents = await task + + # Content should be JSON serialized + assert len(contents) == 1 + import json + + data = json.loads(contents[0].text) + assert data == {"key": "value", "count": 123} + + +# ============================================================================== +# Binary & Special Types +# ============================================================================== + + +@pytest.fixture +async def binary_type_server(): + """Server with tools returning binary and special types.""" + mcp = FastMCP("binary-test") + + @mcp.tool(task=True) + async def return_bytes() -> bytes: + return b"Hello bytes!" + + @mcp.tool(task=True) + async def return_uuid() -> UUID: + return UUID("12345678-1234-5678-1234-567812345678") + + @mcp.tool(task=True) + async def return_path() -> Path: + return Path("/tmp/test.txt") + + @mcp.tool(task=True) + async def return_datetime() -> datetime: + return datetime(2025, 11, 5, 12, 30, 45) + + return mcp + + +@pytest.mark.parametrize( + "tool_name,expected_type,assertion_fn", + [ + ( + "return_bytes", + type(None), + lambda r: ( + r.data is None and any("Hello bytes!" in c.text for c in r.content) + ), + ), + ( + "return_uuid", + str, + lambda r: r.data == "12345678-1234-5678-1234-567812345678", + ), + ( + "return_path", + str, + lambda r: "tmp" in r.data and "test.txt" in r.data, + ), + ( + "return_datetime", + datetime, + lambda r: r.data == datetime(2025, 11, 5, 12, 30, 45), + ), + ], +) +async def test_task_binary_types( + binary_type_server: FastMCP, + tool_name: str, + expected_type: type, + assertion_fn: Any, +): + """Task mode handles binary and special types.""" + async with Client(binary_type_server) as client: + task = await client.call_tool(tool_name, task=True) + result = await task + assert isinstance(result.data, expected_type) + assert assertion_fn(result) + + +# ============================================================================== +# Collection Varieties +# ============================================================================== + + +@pytest.fixture +async def collection_server(): + """Server with tools returning various collection types.""" + mcp = FastMCP("collection-test") + + @mcp.tool(task=True) + async def return_tuple() -> tuple[int, str, bool]: + return (42, "hello", True) + + @mcp.tool(task=True) + async def return_set() -> set[int]: + return {1, 2, 3} + + @mcp.tool(task=True) + async def return_empty_list() -> list[str]: + return [] + + @mcp.tool(task=True) + async def return_empty_dict() -> dict[str, Any]: + return {} + + return mcp + + +@pytest.mark.parametrize( + "tool_name,expected_type,expected_value", + [ + ("return_tuple", list, [42, "hello", True]), + ("return_set", set, {1, 2, 3}), + ("return_empty_list", list, []), + ], +) +async def test_task_collection_types( + collection_server: FastMCP, + tool_name: str, + expected_type: type, + expected_value: Any, +): + """Task mode handles collection types.""" + async with Client(collection_server) as client: + task = await client.call_tool(tool_name, task=True) + result = await task + assert isinstance(result.data, expected_type) + assert result.data == expected_value + + +async def test_task_empty_dict_return(collection_server): + """Task mode handles empty dict return.""" + async with Client(collection_server) as client: + task = await client.call_tool("return_empty_dict", task=True) + result = await task + # Empty structured content becomes None in data + assert result.data is None + # But structured content is still {} + assert result.structured_content == {} + + +# ============================================================================== +# Media Types (Image, Audio, File) +# ============================================================================== + + +@pytest.fixture +async def media_server(tmp_path): + """Server with tools returning media types.""" + mcp = FastMCP("media-test") + + # Create test files + test_image = tmp_path / "test.png" + test_image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fake png data") + + test_audio = tmp_path / "test.mp3" + test_audio.write_bytes(b"ID3" + b"fake mp3 data") + + test_file = tmp_path / "test.txt" + test_file.write_text("test file content") + + @mcp.tool(task=True) + async def return_image_path() -> Image: + return Image(path=str(test_image)) + + @mcp.tool(task=True) + async def return_image_data() -> Image: + return Image(data=test_image.read_bytes(), format="png") + + @mcp.tool(task=True) + async def return_audio() -> Audio: + return Audio(path=str(test_audio)) + + @mcp.tool(task=True) + async def return_file() -> File: + return File(path=str(test_file)) + + return mcp + + +@pytest.mark.parametrize( + "tool_name,assertion_fn", + [ + ( + "return_image_path", + lambda r: len(r.content) == 1 and r.content[0].type == "image", + ), + ( + "return_image_data", + lambda r: ( + len(r.content) == 1 + and r.content[0].type == "image" + and r.content[0].mime_type == "image/png" + ), + ), + ( + "return_audio", + lambda r: len(r.content) == 1 and r.content[0].type in ["text", "audio"], + ), + ( + "return_file", + lambda r: len(r.content) == 1 and r.content[0].type == "resource", + ), + ], +) +async def test_task_media_types( + media_server: FastMCP, + tool_name: str, + assertion_fn: Any, +): + """Task mode handles media types (Image, Audio, File).""" + async with Client(media_server) as client: + task = await client.call_tool(tool_name, task=True) + result = await task + assert assertion_fn(result) + + +# ============================================================================== +# Structured Types (TypedDict, dataclass, unions) +# ============================================================================== + + +class PersonTypedDict(TypedDict): + """Example TypedDict.""" + + name: str + age: int + + +@dataclass +class PersonDataclass: + """Example dataclass.""" + + name: str + age: int + + +@pytest.fixture +async def structured_type_server(): + """Server with tools returning structured types.""" + mcp = FastMCP("structured-test") + + @mcp.tool(task=True) + async def return_typeddict() -> PersonTypedDict: + return {"name": "Bob", "age": 25} + + @mcp.tool(task=True) + async def return_dataclass() -> PersonDataclass: + return PersonDataclass(name="Charlie", age=35) + + @mcp.tool(task=True) + async def return_union() -> str | int: + return "string value" + + @mcp.tool(task=True) + async def return_union_int() -> str | int: + return 123 + + @mcp.tool(task=True) + async def return_optional() -> str | None: + return "has value" + + @mcp.tool(task=True) + async def return_optional_none() -> str | None: + return None + + return mcp + + +@pytest.mark.parametrize( + "tool_name,expected_name,expected_age", + [ + ("return_typeddict", "Bob", 25), + ("return_dataclass", "Charlie", 35), + ], +) +async def test_task_structured_dict_types( + structured_type_server: FastMCP, + tool_name: str, + expected_name: str, + expected_age: int, +): + """Task mode handles TypedDict and dataclass returns.""" + async with Client(structured_type_server) as client: + task = await client.call_tool(tool_name, task=True) + result = await task + # Both deserialize to dynamic Root class + assert result.data.name == expected_name + assert result.data.age == expected_age + + +@pytest.mark.parametrize( + "tool_name,expected_type,expected_value", + [ + ("return_union", str, "string value"), + ("return_union_int", int, 123), + ], +) +async def test_task_union_types( + structured_type_server: FastMCP, + tool_name: str, + expected_type: type, + expected_value: Any, +): + """Task mode handles union type branches.""" + async with Client(structured_type_server) as client: + task = await client.call_tool(tool_name, task=True) + result = await task + assert isinstance(result.data, expected_type) + assert result.data == expected_value + + +@pytest.mark.parametrize( + "tool_name,expected_type,expected_value", + [ + ("return_optional", str, "has value"), + ("return_optional_none", type(None), None), + ], +) +async def test_task_optional_types( + structured_type_server: FastMCP, + tool_name: str, + expected_type: type, + expected_value: Any, +): + """Task mode handles Optional types.""" + async with Client(structured_type_server) as client: + task = await client.call_tool(tool_name, task=True) + result = await task + assert isinstance(result.data, expected_type) + assert result.data == expected_value + + +# ============================================================================== +# MCP Content Blocks +# ============================================================================== + + +@pytest.fixture +async def mcp_content_server(tmp_path): + """Server with tools returning MCP content blocks.""" + import base64 + + from mcp_types import ( + EmbeddedResource, + ImageContent, + ResourceLink, + TextContent, + TextResourceContents, + ) + + mcp = FastMCP("content-test") + + test_image = tmp_path / "content.png" + test_image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"content") + + @mcp.tool(task=True) + async def return_text_content() -> TextContent: + return TextContent(type="text", text="Direct text content") + + @mcp.tool(task=True) + async def return_image_content() -> ImageContent: + return ImageContent( + type="image", + data=base64.b64encode(test_image.read_bytes()).decode(), + mime_type="image/png", + ) + + @mcp.tool(task=True) + async def return_embedded_resource() -> EmbeddedResource: + return EmbeddedResource( + type="resource", + resource=TextResourceContents(uri="test://resource", text="embedded"), + ) + + @mcp.tool(task=True) + async def return_resource_link() -> ResourceLink: + return ResourceLink( + type="resource_link", uri="test://linked", name="Test Resource" + ) + + @mcp.tool(task=True) + async def return_mixed_content() -> list[TextContent | ImageContent]: + return [ + TextContent(type="text", text="First block"), + ImageContent( + type="image", + data=base64.b64encode(test_image.read_bytes()).decode(), + mime_type="image/png", + ), + TextContent(type="text", text="Third block"), + ] + + return mcp + + +@pytest.mark.parametrize( + "tool_name,assertion_fn", + [ + ( + "return_text_content", + lambda r: ( + len(r.content) == 1 + and r.content[0].type == "text" + and r.content[0].text == "Direct text content" + ), + ), + ( + "return_image_content", + lambda r: ( + len(r.content) == 1 + and r.content[0].type == "image" + and r.content[0].mime_type == "image/png" + ), + ), + ( + "return_embedded_resource", + lambda r: len(r.content) == 1 and r.content[0].type == "resource", + ), + ( + "return_resource_link", + lambda r: ( + len(r.content) == 1 + and r.content[0].type == "resource_link" + and str(r.content[0].uri) == "test://linked" + ), + ), + ], +) +async def test_task_mcp_content_types( + mcp_content_server: FastMCP, + tool_name: str, + assertion_fn: Any, +): + """Task mode handles MCP content block types.""" + async with Client(mcp_content_server) as client: + task = await client.call_tool(tool_name, task=True) + result = await task + assert assertion_fn(result) + + +async def test_task_mixed_content_return(mcp_content_server): + """Task mode handles mixed content list return.""" + async with Client(mcp_content_server) as client: + task = await client.call_tool("return_mixed_content", task=True) + result = await task + assert len(result.content) == 3 + assert result.content[0].type == "text" + assert result.content[0].text == "First block" + assert result.content[1].type == "image" + assert result.content[2].type == "text" + assert result.content[2].text == "Third block" diff --git a/tests/server/tasks/test_task_security.py b/tests/server/tasks/test_task_security.py new file mode 100644 index 000000000..5d3b16ffa --- /dev/null +++ b/tests/server/tasks/test_task_security.py @@ -0,0 +1,149 @@ +""" +Tests for authorization-based task isolation (CRITICAL SECURITY). + +Ensures that tasks are properly scoped to authorization identity and clients +cannot access each other's tasks. +""" + +import pytest +from mcp.server.auth.middleware.auth_context import ( + auth_context_var, +) +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.auth import AccessToken + + +@pytest.fixture +def task_server(): + """Create a server with background tasks enabled.""" + mcp = FastMCP("security-test-server") + + @mcp.tool(task=True) + async def secret_tool(data: str) -> str: + """A tool that processes sensitive data.""" + return f"Secret result: {data}" + + return mcp + + +async def test_same_client_can_access_all_its_tasks(task_server: FastMCP): + """A single authenticated client can access all tasks it created.""" + token = AccessToken( + token="token-a", + client_id="client-a", + scopes=["read"], + ) + reset = auth_context_var.set(AuthenticatedUser(token)) + try: + async with Client(task_server) as client: + task1 = await client.call_tool( + "secret_tool", {"data": "first"}, task=True, task_id="task-1" + ) + task2 = await client.call_tool( + "secret_tool", {"data": "second"}, task=True, task_id="task-2" + ) + + await task1.wait(timeout=2.0) + await task2.wait(timeout=2.0) + + result1 = await task1.result() + result2 = await task2.result() + + assert "first" in str(result1.data) + assert "second" in str(result2.data) + finally: + auth_context_var.reset(reset) + + +async def test_unauthenticated_client_can_access_its_tasks(task_server: FastMCP): + """An unauthenticated client can access tasks it created (by task ID).""" + async with Client(task_server) as client: + task = await client.call_tool( + "secret_tool", {"data": "hello"}, task=True, task_id="my-task" + ) + await task.wait(timeout=2.0) + result = await task.result() + assert "hello" in str(result.data) + + +def _set_auth(client_id: str, sub: str | None = None): + """Install an auth context for a given client_id/sub. Returns the reset token.""" + claims = {"sub": sub} if sub else {} + token = AccessToken( + token=f"token-{client_id}-{sub or ''}", + client_id=client_id, + scopes=["read"], + claims=claims, + ) + return auth_context_var.set(AuthenticatedUser(token)) + + +async def _submit_task_id(client: Client, data: str) -> str: + """Submit a background task and return its server-assigned task id.""" + task = await client.call_tool("secret_tool", {"data": data}, task=True) + await task.wait(timeout=2.0) + return task.task_id + + +async def test_distinct_clients_cannot_access_each_others_tasks( + task_server: FastMCP, +): + """Two distinct authenticated clients live in disjoint scopes — looking up + a peer's task id returns 'not found'.""" + reset = _set_auth("client-a") + try: + async with Client(task_server) as client_a: + task_id = await _submit_task_id(client_a, "client-a-secret") + finally: + auth_context_var.reset(reset) + + reset = _set_auth("client-b") + try: + async with Client(task_server) as client_b: + with pytest.raises(Exception, match="not found"): + await client_b.get_task_status(task_id) + finally: + auth_context_var.reset(reset) + + +async def test_distinct_subs_same_client_id_cannot_access_each_others_tasks( + task_server: FastMCP, +): + """Fixed-OAuth case: two users share a client_id but have distinct ``sub`` + claims. The ``sub``-aware scope must still isolate them.""" + shared_client = "shared-oauth-app" + + reset = _set_auth(shared_client, sub="user-alice") + try: + async with Client(task_server) as alice: + task_id = await _submit_task_id(alice, "alice-secret") + finally: + auth_context_var.reset(reset) + + reset = _set_auth(shared_client, sub="user-bob") + try: + async with Client(task_server) as bob: + with pytest.raises(Exception, match="not found"): + await bob.get_task_status(task_id) + finally: + auth_context_var.reset(reset) + + +async def test_authenticated_and_anonymous_keyspaces_are_disjoint( + task_server: FastMCP, +): + """An anonymous client must not be able to read an authenticated client's + tasks (and vice versa) even when colliding on task id.""" + reset = _set_auth("client-a") + try: + async with Client(task_server) as authed: + authed_task_id = await _submit_task_id(authed, "authed-secret") + finally: + auth_context_var.reset(reset) + + async with Client(task_server) as anon: + with pytest.raises(Exception, match="not found"): + await anon.get_task_status(authed_task_id) diff --git a/tests/server/tasks/test_task_status_notifications.py b/tests/server/tasks/test_task_status_notifications.py new file mode 100644 index 000000000..98d333ca9 --- /dev/null +++ b/tests/server/tasks/test_task_status_notifications.py @@ -0,0 +1,160 @@ +""" +Tests for notifications/tasks/status subscription mechanism (SEP-1686 lines 436-444). + +Per the spec, servers MAY send notifications/tasks/status when task state changes. +This is an optional optimization that reduces client polling frequency. + +These tests verify that the subscription mechanism works correctly without breaking +existing functionality. Notification delivery is best-effort and clients MUST NOT +rely on receiving them. +""" + +import asyncio + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client + + +@pytest.fixture +async def notification_server(): + """Create a server for testing task status notifications.""" + mcp = FastMCP("notification-test") + + @mcp.tool(task=True) + async def quick_task(value: int) -> int: + """Quick task that completes immediately.""" + return value * 2 + + @mcp.tool(task=True) + async def slow_task(duration: float = 0.1) -> str: + """Slow task for testing working status.""" + await asyncio.sleep(duration) + return "completed" + + @mcp.tool(task=True) + async def failing_task() -> str: + """Task that always fails.""" + raise ValueError("Task failed intentionally") + + @mcp.prompt(task=True) + async def test_prompt(name: str) -> str: + """Test prompt for background execution.""" + await asyncio.sleep(0.05) + return f"Hello, {name}!" + + @mcp.resource("test://resource", task=True) + async def test_resource() -> str: + """Test resource for background execution.""" + await asyncio.sleep(0.05) + return "resource content" + + return mcp + + +async def test_subscription_spawned_for_tool_task(notification_server: FastMCP): + """Subscription task is spawned when tool task is created.""" + async with Client(notification_server) as client: + # Create task - should spawn subscription + task = await client.call_tool("quick_task", {"value": 5}, task=True) + + # Task should complete normally + result = await task + assert result.data == 10 + + # Subscription should clean up automatically + # (No way to directly test, but shouldn't cause issues) + + +async def test_subscription_handles_task_completion(notification_server: FastMCP): + """Subscription properly handles task completion and cleanup.""" + async with Client(notification_server) as client: + # Multiple tasks should each get their own subscription + task1 = await client.call_tool("quick_task", {"value": 1}, task=True) + task2 = await client.call_tool("quick_task", {"value": 2}, task=True) + task3 = await client.call_tool("quick_task", {"value": 3}, task=True) + + # All should complete successfully + result1 = await task1 + result2 = await task2 + result3 = await task3 + + assert result1.data == 2 + assert result2.data == 4 + assert result3.data == 6 + + # Subscriptions should all clean up + # Give them a moment + await asyncio.sleep(0.1) + + +async def test_subscription_handles_task_failure(notification_server: FastMCP): + """Subscription properly handles task failure.""" + async with Client(notification_server) as client: + task = await client.call_tool("failing_task", {}, task=True) + + # Task should fail + with pytest.raises(Exception): + await task + + # Subscription should handle failure and clean up + await asyncio.sleep(0.1) + + +async def test_subscription_for_prompt_tasks(notification_server: FastMCP): + """Subscriptions work for prompt tasks.""" + async with Client(notification_server) as client: + task = await client.get_prompt("test_prompt", {"name": "World"}, task=True) + + result = await task + # Prompt result has messages + assert result + + # Subscription should clean up + await asyncio.sleep(0.1) + + +async def test_subscription_for_resource_tasks(notification_server: FastMCP): + """Subscriptions work for resource tasks.""" + async with Client(notification_server) as client: + task = await client.read_resource("test://resource", task=True) + + result = await task + assert result # Resource contents + + # Subscription should clean up + await asyncio.sleep(0.1) + + +async def test_subscriptions_cleanup_on_session_disconnect( + notification_server: FastMCP, +): + """Subscriptions are cleaned up when session disconnects.""" + # Start session and create task + async with Client(notification_server) as client: + task = await client.call_tool("slow_task", {"duration": 1.0}, task=True) + task_id = task.task_id + # Disconnect before task completes (session __aexit__ cancels subscriptions) + + # Session is now closed, subscription should be cancelled + # Task continues in Docket but notification subscription is gone + # This test passing means no crash occurred during cleanup + assert task_id # Task was created + + +async def test_multiple_concurrent_subscriptions(notification_server: FastMCP): + """Multiple concurrent tasks each have their own subscription.""" + async with Client(notification_server) as client: + # Start many tasks concurrently + tasks = [] + for i in range(10): + task = await client.call_tool("quick_task", {"value": i}, task=True) + tasks.append(task) + + # All should complete + results = await asyncio.gather(*tasks) + assert len(results) == 10 + + # All subscriptions should clean up + await asyncio.sleep(0.1) diff --git a/tests/server/tasks/test_task_tools.py b/tests/server/tasks/test_task_tools.py new file mode 100644 index 000000000..1515504d1 --- /dev/null +++ b/tests/server/tasks/test_task_tools.py @@ -0,0 +1,243 @@ +""" +Tests for server-side tool task behavior. + +Tests tool-specific task handling, parallel to test_task_prompts.py +and test_task_resources.py. +""" + +import asyncio +import functools + +import mcp_types +import pytest +from pydantic import BaseModel + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.messages import MessageHandler +from fastmcp.client.tasks import ToolTask +from fastmcp.exceptions import ToolError +from fastmcp.tools.function_tool import _resolve_param_hints + + +@pytest.fixture +async def tool_server(): + """Create a FastMCP server with task-enabled tools.""" + mcp = FastMCP("tool-task-server") + + @mcp.tool(task=True) + async def simple_tool(message: str) -> str: + """A simple tool for testing.""" + return f"Processed: {message}" + + @mcp.tool(task=False) + async def sync_only_tool(message: str) -> str: + """Tool with task=False.""" + return f"Sync: {message}" + + return mcp + + +class _Item(BaseModel): + value: str + + +async def test_task_tool_validates_model_arguments(): + """Model-typed args are coerced to model instances for task calls (#4349). + + The synchronous path validates arguments through the function's + TypeAdapter, so a parameter typed as a Pydantic model arrives as a model + instance. The task path must coerce the same way rather than passing the + raw dict through to the function. + """ + mcp = FastMCP("tool-task-validation-server") + + @mcp.tool(task=True) + async def inspect_items(item: _Item, items: list[_Item]) -> dict[str, str]: + return {"item": type(item).__name__, "element": type(items[0]).__name__} + + arguments = {"item": {"value": "a"}, "items": [{"value": "b"}]} + expected = {"item": "_Item", "element": "_Item"} + + async with Client(mcp) as client: + sync_result = await client.call_tool("inspect_items", arguments) + task = await client.call_tool("inspect_items", arguments, task=True) + task_result = await task.result() + + assert sync_result.data == expected + assert task_result.data == expected + + +async def test_task_tool_invalid_arguments_fail_before_task_state(): + """Invalid task arguments are rejected before any task state is created. + + Coercion runs up front in submit_to_docket, so a validation failure surfaces + before the task's Redis metadata and initial "working" status notification + are written. Otherwise an invalid input would orphan a task the client had + already observed via that notification. + """ + + class _Recorder(MessageHandler): + def __init__(self): + super().__init__() + self.methods: list[str] = [] + + async def on_notification(self, message: mcp_types.ServerNotification) -> None: + self.methods.append(message.method) + + server = FastMCP("tool-task-invalid-args-server") + + @server.tool(task=True) + async def needs_item(item: _Item) -> str: + return item.value + + recorder = _Recorder() + async with Client(server, message_handler=recorder) as client: + # `item` is missing its required `value` field. + task = await client.call_tool("needs_item", {"item": {}}, task=True) + assert task.returned_immediately + with pytest.raises(ToolError): + await task.result() + + assert "notifications/tasks/status" not in recorder.methods + + +async def test_task_submission_honors_strict_input_validation(): + """Strict input validation applies to task submissions, not just sync calls. + + With ``strict_input_validation=True``, a lax coercion like ``{"n": "1"}`` + for an ``int`` parameter is rejected on the synchronous path. The task + submission path must reject it identically rather than silently coercing + and queueing it — otherwise ``task=True`` would bypass the strict flag. + """ + + class _Recorder(MessageHandler): + def __init__(self): + super().__init__() + self.methods: list[str] = [] + + async def on_notification(self, message: mcp_types.ServerNotification) -> None: + self.methods.append(message.method) + + server = FastMCP("strict-task-server", strict_input_validation=True) + + @server.tool(task=True) + async def square(n: int) -> int: + return n * n + + recorder = _Recorder() + async with Client(server, message_handler=recorder) as client: + # Sync path rejects the string-for-int coercion under strict validation. + with pytest.raises(ToolError): + await client.call_tool("square", {"n": "1"}) + + # Task path must reject it too, before any task state is created — so no + # status notification is emitted for the orphaned submission. + task = await client.call_tool("square", {"n": "1"}, task=True) + assert task.returned_immediately + with pytest.raises(ToolError): + await task.result() + + assert "notifications/tasks/status" not in recorder.methods + + +async def test_task_submission_valid_argument_under_strict_validation(): + """A well-typed argument still submits fine when strict validation is on.""" + server = FastMCP("strict-task-valid-server", strict_input_validation=True) + + @server.tool(task=True) + async def square(n: int) -> int: + return n * n + + async with Client(server) as client: + task = await client.call_tool("square", {"n": 4}, task=True) + assert not task.returned_immediately + result = await task.result() + assert result.data == 16 + + +def test_resolve_param_hints_handles_partials(): + """Partials aren't introspectable by get_type_hints; resolve via the func. + + Argument coercion must not raise for partial-wrapped callables — it should + resolve hints for the still-unbound parameters. + """ + + async def base(prefix: str, items: list[_Item]) -> str: + return prefix + + partial_fn = functools.partial(base, "bound") + hints = _resolve_param_hints(partial_fn) + + assert hints["items"] == list[_Item] + + +async def test_synchronous_tool_call_unchanged(tool_server): + """Tools without task metadata execute synchronously as before.""" + async with Client(tool_server) as client: + # Regular call without task metadata + result = await client.call_tool("simple_tool", {"message": "hello"}) + + # Should execute immediately and return result + assert "Processed: hello" in str(result) + + +async def test_tool_with_task_metadata_returns_immediately(tool_server): + """Tools with task metadata return immediately with ToolTask object.""" + async with Client(tool_server) as client: + # Call with task metadata + task = await client.call_tool("simple_tool", {"message": "test"}, task=True) + assert task + assert not task.returned_immediately + + assert isinstance(task, ToolTask) + assert isinstance(task.task_id, str) + assert len(task.task_id) > 0 + + +async def test_tool_task_executes_in_background(tool_server): + """Tool task is submitted to Docket and executes in background.""" + execution_started = asyncio.Event() + execution_completed = asyncio.Event() + + @tool_server.tool(task=True) + async def coordinated_tool() -> str: + """Tool with coordination points.""" + execution_started.set() + await execution_completed.wait() + return "completed" + + async with Client(tool_server) as client: + task = await client.call_tool("coordinated_tool", task=True) + assert task + assert not task.returned_immediately + + # Wait for execution to start + await asyncio.wait_for(execution_started.wait(), timeout=2.0) + + # Task should still be working + status = await task.status() + assert status.status in ["working"] + + # Signal completion + execution_completed.set() + await task.wait(timeout=2.0) + + result = await task.result() + assert result.data == "completed" + + +async def test_forbidden_mode_tool_rejects_task_calls(tool_server): + """Tools with task=False (mode=forbidden) reject task-augmented calls.""" + async with Client(tool_server) as client: + # Calling with task=True when task=False should return error + task = await client.call_tool( + "sync_only_tool", {"message": "test"}, task=True, raise_on_error=False + ) + assert task + assert task.returned_immediately + + result = await task.result() + # New behavior: mode="forbidden" returns an error + assert result.is_error + assert "does not support task-augmented execution" in str(result) diff --git a/tests/server/tasks/test_task_ttl.py b/tests/server/tasks/test_task_ttl.py new file mode 100644 index 000000000..769fafffa --- /dev/null +++ b/tests/server/tasks/test_task_ttl.py @@ -0,0 +1,87 @@ +""" +Tests for SEP-1686 ttl parameter handling. + +Per the spec, servers MUST return ttl in all tasks/get responses, +and results should be retained for ttl milliseconds after completion. +""" + +import asyncio + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client + + +@pytest.fixture +async def keepalive_server(): + """Create a server for testing ttl behavior.""" + mcp = FastMCP("keepalive-test") + + @mcp.tool(task=True) + async def quick_task(value: int) -> int: + return value * 2 + + @mcp.tool(task=True) + async def slow_task() -> str: + await asyncio.sleep(1) + return "done" + + return mcp + + +async def test_keepalive_returned_in_submitted_state(keepalive_server: FastMCP): + """ttl is returned in tasks/get even when task is submitted/working.""" + async with Client(keepalive_server) as client: + # Submit task with explicit ttl + task = await client.call_tool( + "slow_task", + {}, + task=True, + ttl=30000, # 30 seconds (client-requested) + ) + + # Check status immediately - should be submitted or working + status = await task.status() + assert status.status in ["working"] + + # ttl should be present per spec (MUST return in all responses) + # TODO: Docket uses a global execution_ttl for all tasks, not per-task TTLs. + # The spec allows servers to override client-requested TTL (line 431). + # FastMCP returns the server's actual global TTL (60000ms default from Docket). + # If Docket gains per-task TTL support, update this to verify client-requested TTL is respected. + assert status.ttl == 60000 # Server's global TTL, not client-requested 30000 + + +async def test_keepalive_returned_in_completed_state(keepalive_server: FastMCP): + """ttl is returned in tasks/get after task completes.""" + async with Client(keepalive_server) as client: + # Submit and complete task + task = await client.call_tool( + "quick_task", + {"value": 5}, + task=True, + ttl=45000, # Client-requested TTL + ) + await task.wait(timeout=2.0) + + # Check status - should be completed + status = await task.status() + assert status.status == "completed" + + # TODO: Docket uses global execution_ttl, not per-task TTLs. + # Server returns its global TTL (60000ms), not the client-requested 45000ms. + # This is spec-compliant - servers MAY override requested TTL (spec line 431). + assert status.ttl == 60000 # Server's global TTL, not client-requested 45000 + + +async def test_default_keepalive_when_not_specified(keepalive_server: FastMCP): + """Default ttl is used when client doesn't specify.""" + async with Client(keepalive_server) as client: + # Submit without explicit ttl + task = await client.call_tool("quick_task", {"value": 3}, task=True) + await task.wait(timeout=2.0) + + status = await task.status() + # Should have default ttl (60000ms = 60 seconds) + assert status.ttl == 60000 diff --git a/tests/server/telemetry/test_provider_tracing.py b/tests/server/telemetry/test_provider_tracing.py index 50c33f8fe..7118044a2 100644 --- a/tests/server/telemetry/test_provider_tracing.py +++ b/tests/server/telemetry/test_provider_tracing.py @@ -4,9 +4,7 @@ from __future__ import annotations from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from fastmcp import Client, Context, FastMCP -from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient -from fastmcp.telemetry import TRACE_PARENT_KEY +from fastmcp import FastMCP class TestFastMCPProviderTracing: @@ -133,53 +131,3 @@ class TestProviderSpanHierarchy: assert child_span.parent is not None assert delegate_span.parent.span_id == parent_span.context.span_id assert child_span.parent.span_id == delegate_span.context.span_id - - -class TestModernProxyTracePropagation: - """A modern proxy relays resources and prompts through the low-level - session so a backend guard's ask can surface (SEP-2322). That path skips - the high-level client's trace injection, so the relay must stamp the - outgoing `_meta` itself — otherwise every modern proxy read breaks the - distributed trace, not only the guard rounds.""" - - @staticmethod - def _backend(seen: dict[str, dict]) -> FastMCP: - backend = FastMCP("trace-backend") - - def record(kind: str, ctx: Context) -> None: - rc = ctx.request_context - seen[kind] = dict(rc.meta) if rc is not None and rc.meta else {} - - @backend.resource("data://x") - async def concrete(ctx: Context) -> str: - record("resource", ctx) - return "ok" - - @backend.resource("data://{part}/y") - async def templated(part: str, ctx: Context) -> str: - record("template", ctx) - return "ok" - - @backend.prompt - async def greet(ctx: Context) -> str: - record("prompt", ctx) - return "ok" - - return backend - - async def test_traceparent_reaches_backend( - self, trace_exporter: InMemorySpanExporter - ): - seen: dict[str, dict] = {} - proxy = FastMCPProxy( - client_factory=lambda: ProxyClient(self._backend(seen), mode="auto") - ) - - async with Client(proxy, mode="auto") as client: - await client.read_resource("data://x") - await client.read_resource("data://p/y") - await client.get_prompt("greet") - - assert TRACE_PARENT_KEY in seen["resource"] - assert TRACE_PARENT_KEY in seen["template"] - assert TRACE_PARENT_KEY in seen["prompt"] diff --git a/tests/server/telemetry/test_sampling_tracing.py b/tests/server/telemetry/test_sampling_tracing.py new file mode 100644 index 000000000..063b8b1ee --- /dev/null +++ b/tests/server/telemetry/test_sampling_tracing.py @@ -0,0 +1,330 @@ +"""Tracing coverage for sampling create_message and tool-execution spans. + +Regression focus: the `sampling create_message` span is created with +`record_exception=False, set_status_on_exception=False` and records the +exception manually in its `except` block. A failed sampling call must +therefore produce exactly ONE exception event, not two. +""" + +from __future__ import annotations + +import pytest +from mcp_types import TextContent +from opentelemetry.context import Context as OTelContext +from opentelemetry.sdk.trace import Span, SpanProcessor, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult +from opentelemetry.trace import StatusCode + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams + + +class OnStartRecorder(SpanProcessor): + def __init__(self) -> None: + self.attributes: dict[str, dict[str, object]] = {} + + def on_start(self, span: Span, parent_context: OTelContext | None = None) -> None: + self.attributes[span.name] = dict(span.attributes or {}) + + +class NonForwardingSampler(Sampler): + """Samples every span but never forwards the attributes it was handed. + + See `tests/telemetry/test_span_attributes.py` for the full explanation: + OTel's `Tracer.start_span` builds the finished span from + `sampling_result.attributes`, not from the `attributes` kwarg passed to + `start_as_current_span`, so a custom sampler like this one reproduces the + regression where a non-forwarding sampler silently drops FastMCP's + attributes. + """ + + def should_sample( + self, + parent_context: OTelContext | None, + trace_id: int, + name: str, + kind: object = None, + attributes: object = None, + links: object = None, + trace_state: object = None, + ) -> SamplingResult: + return SamplingResult(Decision.RECORD_AND_SAMPLE) + + def get_description(self) -> str: + return "NonForwardingSampler" + + +@pytest.fixture +def on_start_recorder( + monkeypatch: pytest.MonkeyPatch, + trace_exporter: InMemorySpanExporter, +) -> OnStartRecorder: + recorder = OnStartRecorder() + provider = TracerProvider() + provider.add_span_processor(recorder) + provider.add_span_processor(SimpleSpanProcessor(trace_exporter)) + tracer = provider.get_tracer("test") + monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) + return recorder + + +def _spans_named(exporter: InMemorySpanExporter, name: str): + return [s for s in exporter.get_finished_spans() if s.name == name] + + +def _exception_events(span): + return [e for e in span.events if e.name == "exception"] + + +class TestSamplingCreateMessageSpan: + async def test_success_creates_span_with_attributes( + self, + trace_exporter: InMemorySpanExporter, + on_start_recorder: OnStartRecorder, + ): + def sampling_handler( + messages: list[SamplingMessage], + params: SamplingParams, + ctx: RequestContext, + ) -> str: + return "sampled-text" + + mcp = FastMCP("sampling-server") + + @mcp.tool + async def ask(question: str, context: Context) -> str: + result = await context.sample(messages=question) + return result.text or "" + + async with Client(mcp, sampling_handler=sampling_handler) as client: + await client.call_tool("ask", {"question": "hi"}) + + spans = _spans_named(trace_exporter, "sampling create_message") + assert len(spans) == 1 + span = spans[0] + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "sampling/createMessage" + assert span.attributes["fastmcp.server.name"] == "sampling-server" + assert on_start_recorder.attributes["sampling create_message"] == { + "mcp.method.name": "sampling/createMessage", + "fastmcp.server.name": "sampling-server", + } + # Success path must not record any exception. + assert _exception_events(span) == [] + assert span.status.status_code != StatusCode.ERROR + + async def test_failure_records_exception_exactly_once( + self, trace_exporter: InMemorySpanExporter + ): + """Regression: span created with record_exception=False so the manual + record_exception in the except block fires exactly once (no duplicate + exception events from OTel auto-recording on `with` exit).""" + + def sampling_handler( + messages: list[SamplingMessage], + params: SamplingParams, + ctx: RequestContext, + ) -> str: + raise RuntimeError("sampling boom") + + mcp = FastMCP("sampling-server") + + @mcp.tool + async def ask(question: str, context: Context) -> str: + result = await context.sample(messages=question) + return result.text or "" + + with pytest.raises(Exception): + async with Client(mcp, sampling_handler=sampling_handler) as client: + await client.call_tool("ask", {"question": "hi"}) + + spans = _spans_named(trace_exporter, "sampling create_message") + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert span.attributes is not None + assert "error.type" in span.attributes + # The whole point of the fix: exactly one exception event. + assert len(_exception_events(span)) == 1 + + +class TestSamplingToolSpan: + async def test_tool_error_span_records_exception_once( + self, + trace_exporter: InMemorySpanExporter, + on_start_recorder: OnStartRecorder, + ): + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + call_count = 0 + + def boom_tool() -> str: + raise ValueError("tool exploded") + + def sampling_handler( + messages: list[SamplingMessage], + params: SamplingParams, + ctx: RequestContext, + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="boom_tool", + input={}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="done")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def driver(context: Context) -> str: + result = await context.sample(messages="go", tools=[boom_tool]) + return result.text or "" + + async with Client(mcp) as client: + await client.call_tool("driver", {}) + + spans = _spans_named(trace_exporter, "sampling tool boom_tool") + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert span.attributes is not None + assert span.attributes["gen_ai.tool.name"] == "boom_tool" + assert on_start_recorder.attributes["sampling tool boom_tool"] == { + "gen_ai.tool.name": "boom_tool", + "fastmcp.tool.use_id": "call_1", + } + assert "error.type" in span.attributes + # Tool spans catch-and-convert (no re-raise), so OTel auto-recording + # never fires; the manual record_exception must fire exactly once. + assert len(_exception_events(span)) == 1 + + +class TestAttributesSurviveANonForwardingSampler: + """Regression: `sampling create_message` and `sampling tool ...` spans + must keep FastMCP's attributes even when the configured Sampler doesn't + forward the `attributes` it was handed to its `SamplingResult`. + """ + + @pytest.fixture + def non_forwarding_recorder( + self, + monkeypatch: pytest.MonkeyPatch, + trace_exporter: InMemorySpanExporter, + ) -> OnStartRecorder: + recorder = OnStartRecorder() + provider = TracerProvider(sampler=NonForwardingSampler()) + provider.add_span_processor(recorder) + provider.add_span_processor(SimpleSpanProcessor(trace_exporter)) + tracer = provider.get_tracer("test") + monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer) + return recorder + + async def test_create_message_span_keeps_attributes( + self, + trace_exporter: InMemorySpanExporter, + non_forwarding_recorder: OnStartRecorder, + ): + def sampling_handler( + messages: list[SamplingMessage], + params: SamplingParams, + ctx: RequestContext, + ) -> str: + return "sampled-text" + + mcp = FastMCP("sampling-server") + + @mcp.tool + async def ask(question: str, context: Context) -> str: + result = await context.sample(messages=question) + return result.text or "" + + async with Client(mcp, sampling_handler=sampling_handler) as client: + await client.call_tool("ask", {"question": "hi"}) + + spans = _spans_named(trace_exporter, "sampling create_message") + assert len(spans) == 1 + span = spans[0] + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "sampling/createMessage" + assert span.attributes["fastmcp.server.name"] == "sampling-server" + # The sampler never forwards attributes, so on_start legitimately sees + # none — this documents that limitation rather than asserting around it. + assert non_forwarding_recorder.attributes["sampling create_message"] == {} + + async def test_sampling_tool_span_keeps_attributes( + self, + trace_exporter: InMemorySpanExporter, + non_forwarding_recorder: OnStartRecorder, + ): + from mcp_types import CreateMessageResultWithTools, ToolUseContent + + def echo_tool(text: str) -> str: + return text + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], + params: SamplingParams, + ctx: RequestContext, + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="echo_tool", + input={"text": "hi"}, + ) + ], + model="test-model", + stop_reason="toolUse", + ) + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="done")], + model="test-model", + stop_reason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def driver(context: Context) -> str: + result = await context.sample(messages="go", tools=[echo_tool]) + return result.text or "" + + async with Client(mcp) as client: + await client.call_tool("driver", {}) + + spans = _spans_named(trace_exporter, "sampling tool echo_tool") + assert len(spans) == 1 + span = spans[0] + assert span.attributes is not None + assert span.attributes["gen_ai.tool.name"] == "echo_tool" + assert span.attributes["fastmcp.tool.use_id"] == "call_1" + # The sampler never forwards attributes, so on_start legitimately sees + # none — this documents that limitation rather than asserting around it. + assert non_forwarding_recorder.attributes["sampling tool echo_tool"] == {} diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py index 252d92b70..3c6594aa5 100644 --- a/tests/server/telemetry/test_server_tracing.py +++ b/tests/server/telemetry/test_server_tracing.py @@ -449,9 +449,7 @@ class TestSeamServerSpan: ): mcp = FastMCP("test-server") - # `logging/setLevel` was dropped from the modern protocol version - # (SEP-2577), so exercising it needs the older protocol. - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: await client.set_logging_level("info") spans = trace_exporter.get_finished_spans() @@ -472,9 +470,7 @@ class TestSeamServerSpan: """A seam-spanned method must produce exactly one SERVER span, not two.""" mcp = FastMCP("test-server") - # `logging/setLevel` only exists on the older protocol; see the pin - # note in `test_set_logging_level_emits_seam_span` above. - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: await client.set_logging_level("info") spans = trace_exporter.get_finished_spans() @@ -587,14 +583,14 @@ class TestTelemetryEnabledByDefault: """Instrumentation is on by default and controllable via the off-switch. FastMCP uses only the OpenTelemetry API, so spans are created unconditionally - and light up when an SDK is configured. `FASTMCP_TELEMETRY_MODE=off` - (`fastmcp.settings.telemetry_mode`) turns span creation off entirely, so no + and light up when an SDK is configured. `FASTMCP_ENABLE_TELEMETRY=false` + (`fastmcp.settings.enable_telemetry`) turns span creation off entirely, so no FastMCP spans are exported even with an SDK configured. """ async def test_spans_fire_by_default(self, trace_exporter: InMemorySpanExporter): """No opt-in required: a tool call produces a span out of the box.""" - assert fastmcp.settings.telemetry_mode == "native" + assert fastmcp.settings.enable_telemetry is True mcp = FastMCP("test-server") @@ -615,7 +611,7 @@ class TestTelemetryEnabledByDefault: ): """With telemetry disabled, no spans are created even with an SDK configured (the exporter fixture installs one).""" - monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off") + monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False) mcp = FastMCP("test-server") @@ -641,7 +637,7 @@ class TestTelemetryEnabledByDefault: are governed by the user's OpenTelemetry SDK, not FastMCP's off-switch, so they may still appear — the assertion filters them out. """ - monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off") + monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False) mcp = FastMCP("test-server") @@ -676,7 +672,7 @@ class TestTelemetryEnabledByDefault: `trace.get_current_span()` inside a handler must still return the caller's enclosing span, and attributes written there must land on it. """ - monkeypatch.setattr(fastmcp.settings, "telemetry_mode", "off") + monkeypatch.setattr(fastmcp.settings, "enable_telemetry", False) tracer = trace.get_tracer("test-enclosing") captured: dict[str, Span] = {} @@ -749,14 +745,10 @@ class TestProtocolVersionAttribute: self, trace_exporter: InMemorySpanExporter ): """Seam-only methods (never reaching the high-level path) also carry the - protocol version. - - Pinned to legacy: `logging/setLevel` is a handshake-era seam method the - modern (2026-07-28) protocol drops, so the span exists only on legacy. - """ + protocol version.""" mcp = FastMCP("test-server") - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: await client.set_logging_level("info") spans = trace_exporter.get_finished_spans() diff --git a/tests/server/test_completions.py b/tests/server/test_completions.py deleted file mode 100644 index 88c0bbceb..000000000 --- a/tests/server/test_completions.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Server-side argument completion (`completion/complete`). - -A FastMCP server answers completion requests through a single handler -registered with `@mcp.completion`. These tests cover both reference kinds -(prompt arguments and resource-template parameters), the capability -declaration, graceful handling of unrecognized references, and parity across -the handshake (`mode="legacy"`) and modern (`mode="auto"`) protocol eras. -""" - -from __future__ import annotations - -import threading -from typing import Any - -import pytest -from mcp_types import ( - Completion, - CompletionArgument, - CompletionContext, - PromptReference, - ResourceTemplateReference, -) - -from fastmcp import Client, FastMCP -from fastmcp.server.completions import normalize_completion - -# Both protocol eras the connection may negotiate. -MODES = ["legacy", "auto"] - - -@pytest.fixture -def completion_server() -> FastMCP: - """A server that completes a prompt argument and a template parameter.""" - mcp = FastMCP("completion-server") - - @mcp.prompt - def poem(theme: str) -> str: - return f"Write a poem about {theme}" - - @mcp.resource("data://item/{item_id}") - def item(item_id: str) -> str: - return f"item-{item_id}" - - @mcp.completion - def complete(ref, argument, context): - if isinstance(ref, PromptReference) and ref.name == "poem": - if argument.name == "theme": - options = ["nature", "love", "adventure"] - return [o for o in options if o.startswith(argument.value)] - if isinstance(ref, ResourceTemplateReference): - if ref.uri == "data://item/{item_id}" and argument.name == "item_id": - return ["1", "2", "3"] - return None - - return mcp - - -@pytest.mark.parametrize("mode", MODES) -async def test_prompt_argument_completion_returns_candidates(completion_server, mode): - async with Client(completion_server, mode=mode) as client: - result = await client.complete( - PromptReference(name="poem"), - {"name": "theme", "value": "n"}, - ) - assert result.values == ["nature"] - - -@pytest.mark.parametrize("mode", MODES) -async def test_resource_template_completion_returns_candidates(completion_server, mode): - ref = ResourceTemplateReference(uri="data://item/{item_id}") - async with Client(completion_server, mode=mode) as client: - result = await client.complete(ref, {"name": "item_id", "value": ""}) - assert result.values == ["1", "2", "3"] - - -@pytest.mark.parametrize("mode", MODES) -async def test_capability_declared_when_handler_registered(completion_server, mode): - async with Client(completion_server, mode=mode) as client: - capabilities = client.server_capabilities - assert capabilities is not None - assert capabilities.completions is not None - - -@pytest.mark.parametrize("mode", MODES) -async def test_capability_absent_without_handler(mode): - mcp = FastMCP("no-completion") - - @mcp.prompt - def poem(theme: str) -> str: - return f"Write a poem about {theme}" - - async with Client(mcp, mode=mode) as client: - capabilities = client.server_capabilities - assert capabilities is not None - assert capabilities.completions is None - - -@pytest.mark.parametrize("mode", MODES) -async def test_unregistered_ref_returns_empty_completion(completion_server, mode): - async with Client(completion_server, mode=mode) as client: - result = await client.complete( - PromptReference(name="does-not-exist"), - {"name": "theme", "value": "n"}, - ) - assert result.values == [] - - -@pytest.mark.parametrize("mode", MODES) -async def test_unregistered_argument_returns_empty_completion(completion_server, mode): - async with Client(completion_server, mode=mode) as client: - result = await client.complete( - PromptReference(name="poem"), - {"name": "unknown_argument", "value": "x"}, - ) - assert result.values == [] - - -@pytest.mark.parametrize("mode", MODES) -async def test_completion_context_reaches_handler(mode): - """The already-supplied argument values arrive as the handler's context.""" - mcp = FastMCP("context-server") - - @mcp.prompt - def compose(owner: str, repo: str) -> str: - return f"{owner}/{repo}" - - seen: dict[str, str] = {} - - @mcp.completion - def complete(ref, argument, context): - if context is not None and context.arguments: - seen.update(context.arguments) - return ["fastmcp"] - - async with Client(mcp, mode=mode) as client: - result = await client.complete( - PromptReference(name="compose"), - {"name": "repo", "value": "fast"}, - context_arguments={"owner": "prefecthq"}, - ) - assert result.values == ["fastmcp"] - assert seen == {"owner": "prefecthq"} - - -@pytest.mark.parametrize("mode", MODES) -async def test_completion_object_passes_through_pagination_hints(mode): - """Returning a Completion preserves its total / has_more hints.""" - mcp = FastMCP("hints-server") - - @mcp.prompt - def poem(theme: str) -> str: - return f"Write a poem about {theme}" - - @mcp.completion - def complete(ref, argument, context): - return Completion(values=["nature"], total=42, has_more=True) - - async with Client(mcp, mode=mode) as client: - result = await client.complete( - PromptReference(name="poem"), - {"name": "theme", "value": "n"}, - ) - assert result.values == ["nature"] - assert result.total == 42 - assert result.has_more is True - - -async def test_async_completion_handler_is_awaited(): - mcp = FastMCP("async-server") - - @mcp.prompt - def poem(theme: str) -> str: - return f"Write a poem about {theme}" - - @mcp.completion - async def complete(ref, argument, context): - return ["async-value"] - - async with Client(mcp) as client: - result = await client.complete( - PromptReference(name="poem"), - {"name": "theme", "value": ""}, - ) - assert result.values == ["async-value"] - - -async def test_sync_completion_handler_runs_off_event_loop_thread(): - """A sync handler is offloaded to a threadpool so blocking work in it can't - stall the event loop, matching how sync tools/prompts/resources run.""" - mcp = FastMCP("threadpool-server") - - @mcp.prompt - def poem(theme: str) -> str: - return f"Write a poem about {theme}" - - handler_thread: dict[str, int] = {} - - @mcp.completion - def complete(ref, argument, context): - handler_thread["ident"] = threading.get_ident() - return ["value"] - - main_thread = threading.get_ident() - async with Client(mcp) as client: - result = await client.complete( - PromptReference(name="poem"), - {"name": "theme", "value": ""}, - ) - assert result.values == ["value"] - assert handler_thread["ident"] != main_thread - - -def test_completion_decorator_registers_handler(): - """`@mcp.completion` (bare) registers the handler and the wire capability.""" - mcp = FastMCP("decorator-server") - - @mcp.completion - def complete(ref, argument, context): - return None - - assert mcp._completion_handler is complete - assert "completion/complete" in mcp._mcp_server._request_handlers - - -def test_completion_decorator_called_form_registers_handler(): - """`@mcp.completion()` (called) registers the handler too.""" - mcp = FastMCP("decorator-server") - - @mcp.completion() - def complete(ref, argument, context): - return None - - assert mcp._completion_handler is complete - assert "completion/complete" in mcp._mcp_server._request_handlers - - -@pytest.mark.parametrize( - "value, expected", - [ - (None, []), - ([], []), - (["a", "b"], ["a", "b"]), - (("a", "b"), ["a", "b"]), - ], -) -def test_normalize_completion_coerces_values(value, expected): - assert normalize_completion(value).values == expected - - -def test_normalize_completion_passes_completion_through(): - completion = Completion(values=["x"], total=1) - assert normalize_completion(completion) is completion - - -def test_normalize_completion_truncates_oversized_list_to_100(): - values = [str(i) for i in range(150)] - completion = normalize_completion(values) - assert len(completion.values) == 100 - assert completion.total == 150 - assert completion.has_more is True - - -def test_normalize_completion_truncates_oversized_completion_and_keeps_total(): - completion = normalize_completion( - Completion(values=[str(i) for i in range(150)], total=500) - ) - assert len(completion.values) == 100 - assert completion.total == 500 - assert completion.has_more is True - - -def test_normalize_completion_rejects_bare_string(): - # A bare str is excluded from the handler return type, so this passes it - # through an Any-typed value to exercise the runtime guard for callers who - # bypass type checking. - bad: Any = "oops" - with pytest.raises(TypeError, match="return a list of strings"): - normalize_completion(bad) - - -def test_completion_argument_and_context_types_importable(): - """The completion authoring types are importable from mcp_types.""" - argument = CompletionArgument(name="theme", value="n") - context = CompletionContext(arguments={"owner": "prefecthq"}) - assert argument.name == "theme" - assert context.arguments == {"owner": "prefecthq"} diff --git a/tests/server/test_context.py b/tests/server/test_context.py index 26b57d694..142287b3a 100644 --- a/tests/server/test_context.py +++ b/tests/server/test_context.py @@ -1,12 +1,14 @@ from unittest.mock import MagicMock import pytest +from mcp_types import ModelPreferences from fastmcp.server.context import ( Context, reset_transport, set_transport, ) +from fastmcp.server.sampling.run import _parse_model_preferences from fastmcp.server.server import FastMCP @@ -15,6 +17,28 @@ def context(): return Context(fastmcp=FastMCP()) +class TestParseModelPreferences: + def test_parse_model_preferences_string(self, context): + mp = _parse_model_preferences("claude-haiku-4-5") + assert isinstance(mp, ModelPreferences) + assert mp.hints is not None + assert mp.hints[0].name == "claude-haiku-4-5" + + def test_parse_model_preferences_list(self, context): + mp = _parse_model_preferences(["claude-haiku-4-5", "claude"]) + assert isinstance(mp, ModelPreferences) + assert mp.hints is not None + assert [h.name for h in mp.hints] == ["claude-haiku-4-5", "claude"] + + def test_parse_model_preferences_object(self, context): + obj = ModelPreferences(hints=[]) + assert _parse_model_preferences(obj) is obj + + def test_parse_model_preferences_invalid_type(self, context): + with pytest.raises(ValueError): + _parse_model_preferences(model_preferences=123) # pyright: ignore[reportArgumentType] # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type] + + class TestSessionId: def test_session_id_with_http_headers(self, context): """Test that session_id returns the value from mcp-session-id header.""" @@ -474,7 +498,9 @@ class TestTransportIntegration: async def test_transport_set_via_http_middleware(self): """Test that transport is set per-request via HTTP middleware.""" - from fastmcp.utilities.tests import asgi_client + from fastmcp import Client + from fastmcp.client.transports import StreamableHttpTransport + from fastmcp.utilities.tests import run_server_async mcp = FastMCP("test") observed_transport = None @@ -485,7 +511,9 @@ class TestTransportIntegration: observed_transport = ctx.transport return observed_transport or "none" - async with asgi_client(mcp, transport="streamable-http") as client: - result = await client.call_tool("get_transport", {}) - assert observed_transport == "streamable-http" - assert result.data == "streamable-http" + async with run_server_async(mcp, transport="streamable-http") as url: + transport = StreamableHttpTransport(url=url) + async with Client(transport=transport) as client: + result = await client.call_tool("get_transport", {}) + assert observed_transport == "streamable-http" + assert result.data == "streamable-http" diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index cef7cbf45..818cbaca4 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -3,29 +3,18 @@ from contextlib import asynccontextmanager, contextmanager import pytest -from docket import Docket from mcp_types import TextContent, TextResourceContents from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.dependencies import CurrentContext, Depends, Shared from fastmcp.server.context import Context -from fastmcp_tasks import TasksExtension +from fastmcp.server.dependencies import is_docket_available from tests.conftest import make_server_request_context HUZZAH = "huzzah!" -@pytest.fixture -def reset_docket_memory_server(): - """Force a fresh memory:// Docket server bound to this test's event loop.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - class Connection: """Test connection that tracks whether it's currently open.""" @@ -797,6 +786,9 @@ class TestDependencyInjection: monkeypatch.setattr(importlib.metadata, "version", fake_version) assert dependencies.is_docket_available() is False + # The wrapper that actually failed in #3803 must now return None + # instead of raising ImportError on the inner import. + assert dependencies.get_task_context() is None def test_is_docket_available_false_when_pydocket_not_installed(self, monkeypatch): """``is_docket_available()`` returns False when pydocket is absent.""" @@ -843,7 +835,7 @@ class TestDependencyInjection: def test_require_docket_passes_when_installed(self): """Test require_docket doesn't raise when docket is installed.""" - from fastmcp_tasks.dependencies import require_docket + from fastmcp.server.dependencies import require_docket require_docket("test feature") @@ -857,8 +849,6 @@ class TestDependencyInjection: """ import importlib.metadata - from fastmcp_tasks.dependencies import require_docket - from fastmcp.server import dependencies original_version = importlib.metadata.version @@ -872,7 +862,7 @@ class TestDependencyInjection: monkeypatch.setattr(importlib.metadata, "version", fake_version) with pytest.raises(ImportError, match="pydocket 0.16.6 is installed"): - require_docket("CurrentDocket()") + dependencies.require_docket("CurrentDocket()") def test_dependency_class_exists(self): """Test Dependency and Depends are importable from fastmcp.""" @@ -1205,9 +1195,11 @@ class TestSharedDependencies: ) assert call_count == 1 - async def test_shared_resolves_on_task_capable_server( - self, reset_docket_memory_server - ): + @pytest.mark.skipif( + not is_docket_available(), + reason="requires pydocket for the Docket/Worker lifespan path", + ) + async def test_shared_resolves_on_task_capable_server(self): """Shared() dependencies resolve on a normal request even when the server has task-enabled components. @@ -1218,7 +1210,6 @@ class TestSharedDependencies: on ordinary (non-task) calls. """ mcp = FastMCP("task-capable-server") - mcp.add_extension(TasksExtension()) call_count = 0 diff --git a/tests/server/test_event_store.py b/tests/server/test_event_store.py index 8ff4203f4..edb00b5e8 100644 --- a/tests/server/test_event_store.py +++ b/tests/server/test_event_store.py @@ -1,13 +1,10 @@ """Tests for the EventStore implementation.""" -import asyncio - import pytest from mcp.server.streamable_http import EventMessage from mcp_types import JSONRPCRequest from fastmcp.server.event_store import ( - _LOCK_STRIPES, EventEntry, EventStore, SessionScopedEventStore, @@ -263,87 +260,6 @@ class TestEventStore: assert len(replayed) == 1 -class TestConcurrentStoreEvent: - async def test_concurrent_stores_on_one_stream(self, monkeypatch): - """Concurrent stores must not lose events or evict the same ID twice. - - A live session stores events from more than one task (the SSE writer and - the message router), so the stream's event list is read and written - concurrently. Interleaved, each task appends only its own ID to the list - it read, and both evict the same expired IDs -- the second delete is the - one that raised `FileNotFoundError` on a file-backed store. - """ - event_store = EventStore(max_events_per_stream=2) - - stream_get = event_store._stream_store.get - event_delete = event_store._event_store.delete - deleted: list[str] = [] - - async def yielding_get(**kwargs): - # Suspend between the read and the write so the tasks interleave. - stream_data = await stream_get(**kwargs) - await asyncio.sleep(0) - return stream_data - - async def recording_delete(**kwargs): - deleted.append(kwargs["key"]) - return await event_delete(**kwargs) - - monkeypatch.setattr(event_store._stream_store, "get", yielding_get) - monkeypatch.setattr(event_store._event_store, "delete", recording_delete) - - message = JSONRPCRequest(jsonrpc="2.0", method="test", id=1) - event_ids = await asyncio.gather( - *(event_store.store_event("stream-1", message) for _ in range(5)) - ) - - stream_data = await stream_get(key="stream-1") - assert stream_data is not None - # The two most recent events are retained; every other ID was evicted - # exactly once, and no ID vanished without being evicted. - assert len(stream_data.event_ids) == 2 - assert sorted(stream_data.event_ids + deleted) == sorted(event_ids) - assert len(deleted) == len(set(deleted)) - - async def test_distinct_streams_are_not_serialized(self, monkeypatch): - """Unrelated streams must not wait on each other's backend calls. - - One EventStore is shared by every session, so a store-wide lock would - put a Redis round-trip for one session in front of every other one. - """ - event_store = EventStore() - - # hash() is salted per process, so pick the second stream at runtime. - first = "stream-a" - second = next( - candidate - for candidate in (f"stream-{i}" for i in range(1000)) - if hash(candidate) % _LOCK_STRIPES != hash(first) % _LOCK_STRIPES - ) - - stream_get = event_store._stream_store.get - both_inside = asyncio.Event() - inside = 0 - - async def gate(**kwargs): - nonlocal inside - inside += 1 - if inside == 2: - both_inside.set() - # Both critical sections have to be open at once; a store-wide lock - # would keep the second task out until the first finished. - await asyncio.wait_for(both_inside.wait(), timeout=2) - return await stream_get(**kwargs) - - monkeypatch.setattr(event_store._stream_store, "get", gate) - - message = JSONRPCRequest(jsonrpc="2.0", method="test", id=1) - await asyncio.gather( - event_store.store_event(first, message), - event_store.store_event(second, message), - ) - - class TestEventStoreIntegration: """Integration tests for EventStore with actual message types.""" diff --git a/tests/server/test_extensions.py b/tests/server/test_extensions.py deleted file mode 100644 index c112f67b1..000000000 --- a/tests/server/test_extensions.py +++ /dev/null @@ -1,546 +0,0 @@ -"""Tests for the FastMCP-native server extension API (SEP-2133). - -A synthetic extension exercises every contribution kind: capability -advertisement, additive request methods (with protocol-version gating), -tools/call interception (observe and short-circuit), a lifespan hook (order and -mounted-server behaviour), and the per-request capability sniff. Registration -guards (duplicate identifier, spec-method rejection, invalid identifier) and a -zero-behaviour-change baseline round it out. -""" - -from __future__ import annotations - -from contextlib import asynccontextmanager -from typing import Any, Literal, cast - -import mcp_types -import pytest -from mcp.server.context import ServerRequestContext -from mcp.shared.exceptions import MCPError -from mcp_types import CLIENT_CAPABILITIES_META_KEY, METHOD_NOT_FOUND, RequestParams - -from fastmcp import FastMCP -from fastmcp.client import Client -from fastmcp.server.extensions import ( - MethodBinding, - ServerExtension, - read_client_extension_settings, -) -from fastmcp.tools.base import ToolResult - -EXT_ID = "com.example/synthetic" - - -class PingParams(RequestParams): - echo: str | None = None - - -class PingRequest(mcp_types.Request): - method: Literal["synthetic/ping"] = "synthetic/ping" - params: PingParams - - -class PingResult(mcp_types.Result): - pong: bool - echo: str | None = None - - -def _text(result: mcp_types.CallToolResult) -> str: - block = result.content[0] - assert isinstance(block, mcp_types.TextContent) - return block.text - - -# --------------------------------------------------------------------------- -# Capability advertisement -# --------------------------------------------------------------------------- - - -async def test_capability_advertised_to_modern_client(): - """A registered extension's settings appear under capabilities.extensions. - - Uses ``mode='auto'`` so the client negotiates the modern era via - ``server/discover`` (which reads ``get_capabilities`` directly); the SDK's - version sieve strips ``capabilities.extensions`` only on legacy eras. - """ - - class Ext(ServerExtension): - identifier = EXT_ID - - def settings(self) -> dict[str, Any]: - return {"version": "1"} - - mcp = FastMCP("t") - mcp.add_extension(Ext()) - - async with Client(mcp, mode="auto") as client: - extensions = client.server_capabilities.extensions or {} - assert extensions.get(EXT_ID) == {"version": "1"} - - -async def test_capability_absent_without_registration(): - """A server with no extensions advertises none of its own.""" - mcp = FastMCP("t") - async with Client(mcp, mode="auto") as client: - extensions = client.server_capabilities.extensions or {} - assert EXT_ID not in extensions - - -async def test_empty_settings_still_advertise(): - """The default empty-settings extension is advertised with an empty dict.""" - - class Ext(ServerExtension): - identifier = EXT_ID - - mcp = FastMCP("t") - mcp.add_extension(Ext()) - async with Client(mcp, mode="auto") as client: - extensions = client.server_capabilities.extensions or {} - assert extensions.get(EXT_ID) == {} - - -# --------------------------------------------------------------------------- -# Additive request methods -# --------------------------------------------------------------------------- - - -class _PingExtension(ServerExtension): - identifier = EXT_ID - - def methods(self) -> list[MethodBinding]: - async def handler( - ctx: ServerRequestContext[Any, Any], params: PingParams - ) -> PingResult: - return PingResult(pong=True, echo=params.echo) - - return [ - MethodBinding( - method="synthetic/ping", - params_type=PingParams, - handler=handler, - ) - ] - - -async def test_custom_method_callable_end_to_end(): - mcp = FastMCP("t") - mcp.add_extension(_PingExtension()) - - async with Client(mcp, mode="auto") as client: - result = await client.session.send_request( - request=PingRequest(params=PingParams(echo="hi")), - result_type=PingResult, - ) - assert result.pong is True - assert result.echo == "hi" - - -async def test_method_handler_reaches_server_registry(): - """A method handler can reach the FastMCP component registry via the extension.""" - - class Ext(ServerExtension): - identifier = EXT_ID - - def methods(self) -> list[MethodBinding]: - async def handler( - ctx: ServerRequestContext[Any, Any], params: PingParams - ) -> PingResult: - tools = await self.server.list_tools() - return PingResult(pong=len(tools) == 1) - - return [ - MethodBinding( - method="synthetic/ping", - params_type=PingParams, - handler=handler, - ) - ] - - mcp = FastMCP("t") - - @mcp.tool - def only_tool() -> str: - return "x" - - mcp.add_extension(Ext()) - async with Client(mcp, mode="auto") as client: - result = await client.session.send_request( - request=PingRequest(params=PingParams()), - result_type=PingResult, - ) - assert result.pong is True - - -async def test_method_protocol_version_gating(): - """A version-gated method is rejected as METHOD_NOT_FOUND off its versions.""" - - class Ext(ServerExtension): - identifier = EXT_ID - - def methods(self) -> list[MethodBinding]: - async def handler( - ctx: ServerRequestContext[Any, Any], params: PingParams - ) -> PingResult: - return PingResult(pong=True) - - return [ - MethodBinding( - method="synthetic/ping", - params_type=PingParams, - handler=handler, - protocol_versions=frozenset({"2026-07-28"}), - ) - ] - - mcp = FastMCP("t") - mcp.add_extension(Ext()) - - async with Client(mcp, mode="legacy") as client: - with pytest.raises(MCPError) as exc_info: - await client.session.send_request( - request=PingRequest(params=PingParams()), - result_type=PingResult, - ) - assert exc_info.value.error.code == METHOD_NOT_FOUND - - -# --------------------------------------------------------------------------- -# tools/call interception -# --------------------------------------------------------------------------- - - -async def test_interceptor_observes_tool_call(): - """A pass-through interceptor sees the call and the tool still runs.""" - seen: list[str] = [] - - class Ext(ServerExtension): - identifier = EXT_ID - - async def intercept_tool_call(self, params, context, call_next): - seen.append(params.name) - return await call_next() - - mcp = FastMCP("t") - - @mcp.tool - def greet() -> str: - return "hello" - - mcp.add_extension(Ext()) - async with Client(mcp, mode="auto") as client: - result = await client.call_tool("greet") - assert _text(result) == "hello" - assert seen == ["greet"] - - -async def test_interceptor_short_circuits(): - """An interceptor can return its own result without running the tool body.""" - ran = [] - - class Ext(ServerExtension): - identifier = EXT_ID - - async def intercept_tool_call(self, params, context, call_next): - return ToolResult( - content=[mcp_types.TextContent(type="text", text="intercepted")] - ) - - mcp = FastMCP("t") - - @mcp.tool - def greet(): - ran.append(True) - return "hello" - - mcp.add_extension(Ext()) - async with Client(mcp, mode="auto") as client: - result = await client.call_tool("greet") - assert _text(result) == "intercepted" - assert ran == [] - - -async def test_interceptor_reaches_tool_metadata(): - """An interceptor can resolve the tool being called through the context.""" - captured: dict[str, Any] = {} - - class Ext(ServerExtension): - identifier = EXT_ID - - async def intercept_tool_call(self, params, context, call_next): - tool = await context.fastmcp.get_tool(params.name) - captured["title"] = tool.title - return await call_next() - - mcp = FastMCP("t") - - @mcp.tool(title="A Greeting") - def greet() -> str: - return "hello" - - mcp.add_extension(Ext()) - async with Client(mcp, mode="auto") as client: - await client.call_tool("greet") - assert captured["title"] == "A Greeting" - - -async def test_interceptors_nest_first_registered_outermost(): - """Multiple interceptors nest with the first-registered extension outermost.""" - order: list[str] = [] - - def make_ext(identifier: str, label: str) -> ServerExtension: - class Ext(ServerExtension): - async def intercept_tool_call(self, params, context, call_next): - order.append(f"{label}-before") - result = await call_next() - order.append(f"{label}-after") - return result - - ext = Ext() - ext.identifier = identifier - return ext - - mcp = FastMCP("t") - - @mcp.tool - def greet() -> str: - return "hello" - - mcp.add_extension(make_ext("com.example/outer", "outer")) - mcp.add_extension(make_ext("com.example/inner", "inner")) - async with Client(mcp, mode="auto") as client: - await client.call_tool("greet") - - assert order == ["outer-before", "inner-before", "inner-after", "outer-after"] - - -async def test_no_extensions_leaves_tool_call_unchanged(): - """With no extensions registered, tools/call behaves exactly as before.""" - mcp = FastMCP("t") - - @mcp.tool - def greet() -> str: - return "hello" - - assert mcp._extensions == {} - async with Client(mcp, mode="auto") as client: - result = await client.call_tool("greet") - assert _text(result) == "hello" - - -# --------------------------------------------------------------------------- -# Lifespan hook -# --------------------------------------------------------------------------- - - -def _recording_extension(identifier: str, log: list[str]) -> ServerExtension: - class Ext(ServerExtension): - @asynccontextmanager - async def lifespan(self): - log.append(f"{identifier}:enter") - try: - yield - finally: - log.append(f"{identifier}:exit") - - ext = Ext() - ext.identifier = identifier - return ext - - -async def test_lifespan_entered_and_exited(): - log: list[str] = [] - mcp = FastMCP("t") - mcp.add_extension(_recording_extension(EXT_ID, log)) - - async with Client(mcp, mode="auto"): - assert log == [f"{EXT_ID}:enter"] - assert log == [f"{EXT_ID}:enter", f"{EXT_ID}:exit"] - - -async def test_lifespans_enter_in_order_exit_in_reverse(): - log: list[str] = [] - mcp = FastMCP("t") - mcp.add_extension(_recording_extension("com.example/a", log)) - mcp.add_extension(_recording_extension("com.example/b", log)) - - async with Client(mcp, mode="auto"): - pass - - assert log == [ - "com.example/a:enter", - "com.example/b:enter", - "com.example/b:exit", - "com.example/a:exit", - ] - - -async def test_standalone_server_enters_extension_lifespan(): - log: list[str] = [] - child = FastMCP("child") - child.add_extension(_recording_extension(EXT_ID, log)) - - async with Client(child, mode="auto"): - assert log == [f"{EXT_ID}:enter"] - assert log == [f"{EXT_ID}:enter", f"{EXT_ID}:exit"] - - -async def test_mounted_child_defers_extension_lifespan_to_root(): - """A mounted child's extension lifespan is not entered below a root. - - Mirrors the shared Docket: extension lifespans that may start shared - infrastructure are owned by the tree root, so a mounted child defers. - """ - log: list[str] = [] - child = FastMCP("child") - child.add_extension(_recording_extension(EXT_ID, log)) - - root = FastMCP("root") - root.mount(child) - - async with Client(root, mode="auto"): - pass - - assert log == [] - - -# --------------------------------------------------------------------------- -# Request-time capability sniff -# --------------------------------------------------------------------------- - - -def _ctx_with_meta(meta: dict[str, Any] | None) -> ServerRequestContext[Any, Any]: - params: dict[str, Any] = {} - if meta is not None: - params["_meta"] = meta - return ServerRequestContext( - session=cast(Any, object()), - lifespan_context={}, - protocol_version="2026-07-28", - method="synthetic/ping", - params=params, - ) - - -def test_capability_sniff_reads_declared_settings(): - meta = { - CLIENT_CAPABILITIES_META_KEY: { - "extensions": {EXT_ID: {"limit": 5}}, - } - } - assert read_client_extension_settings(_ctx_with_meta(meta), EXT_ID) == {"limit": 5} - - -def test_capability_sniff_empty_settings_is_opt_in(): - """An empty settings dict is a valid opt-in, distinct from absence (None).""" - meta = {CLIENT_CAPABILITIES_META_KEY: {"extensions": {EXT_ID: {}}}} - assert read_client_extension_settings(_ctx_with_meta(meta), EXT_ID) == {} - - -@pytest.mark.parametrize( - "meta", - [ - None, - {}, - {CLIENT_CAPABILITIES_META_KEY: {}}, - {CLIENT_CAPABILITIES_META_KEY: {"extensions": {"other/ext": {}}}}, - ], -) -def test_capability_sniff_returns_none_when_not_opted_in(meta): - assert read_client_extension_settings(_ctx_with_meta(meta), EXT_ID) is None - - -def test_client_settings_convenience_uses_own_identifier(): - class Ext(ServerExtension): - identifier = EXT_ID - - meta = {CLIENT_CAPABILITIES_META_KEY: {"extensions": {EXT_ID: {"a": 1}}}} - assert Ext().client_settings(_ctx_with_meta(meta)) == {"a": 1} - - -# --------------------------------------------------------------------------- -# Registration guards -# --------------------------------------------------------------------------- - - -async def test_duplicate_identifier_rejected(): - class Ext(ServerExtension): - identifier = EXT_ID - - mcp = FastMCP("t") - mcp.add_extension(Ext()) - with pytest.raises(ValueError, match="already registered"): - mcp.add_extension(Ext()) - - -async def test_registration_after_lifespan_start_rejected(): - """Registering once the server is serving would skip the extension's - lifespan, leaving it silently half-active — so it raises instead.""" - - class Ext(ServerExtension): - identifier = EXT_ID - - mcp = FastMCP("t") - async with Client(mcp, mode="auto"): - with pytest.raises(RuntimeError, match="lifespan has already started"): - mcp.add_extension(Ext()) - - -def test_spec_method_name_rejected(): - async def handler(ctx: Any, params: Any) -> None: - return None - - with pytest.raises(ValueError, match="spec method"): - MethodBinding( - method="tools/call", - params_type=PingParams, - handler=handler, - ) - - -def test_empty_protocol_versions_rejected(): - async def handler(ctx: Any, params: Any) -> None: - return None - - with pytest.raises(ValueError, match="protocol_versions"): - MethodBinding( - method="synthetic/ping", - params_type=PingParams, - handler=handler, - protocol_versions=frozenset(), - ) - - -def test_invalid_identifier_rejected_at_class_definition(): - with pytest.raises(TypeError, match="reverse-DNS"): - - class Ext(ServerExtension): - identifier = "no-prefix" - - -async def test_per_instance_invalid_identifier_rejected_at_registration(): - class Ext(ServerExtension): - pass - - ext = Ext() - ext.identifier = "no-prefix" - mcp = FastMCP("t") - with pytest.raises(TypeError, match="reverse-DNS"): - mcp.add_extension(ext) - - -def test_bound_server_accessible_after_registration(): - class Ext(ServerExtension): - identifier = EXT_ID - - ext = Ext() - mcp = FastMCP("t") - mcp.add_extension(ext) - assert ext.server is mcp - - -def test_unbound_server_access_raises(): - class Ext(ServerExtension): - identifier = EXT_ID - - with pytest.raises(RuntimeError, match="not bound"): - _ = Ext().server diff --git a/tests/server/test_icons.py b/tests/server/test_icons.py index 37ecf5e10..9fba37b9b 100644 --- a/tests/server/test_icons.py +++ b/tests/server/test_icons.py @@ -1,7 +1,6 @@ """Tests for icon support across all MCP object types.""" -import pytest -from mcp_types import Icon, IconTheme +from mcp_types import Icon from fastmcp import Client, FastMCP from fastmcp.prompts import Message, Prompt @@ -37,7 +36,7 @@ class TestServerIcons: # Verify that icons and website_url are passed to the underlying server async with Client(mcp) as client: - server_info = client.session.server_info + server_info = client.initialize_result.server_info assert server_info.website_url == "https://example.com" assert server_info.icons == icons @@ -46,7 +45,7 @@ class TestServerIcons: mcp = FastMCP(name="TestServer") async with Client(mcp) as client: - server_info = client.session.server_info + server_info = client.initialize_result.server_info assert server_info.website_url is None assert server_info.icons is None @@ -291,7 +290,7 @@ class TestIconTypes: mcp = FastMCP("TestServer", icons=icons) async with Client(mcp) as client: - server_info = client.session.server_info + server_info = client.initialize_result.server_info assert len(server_info.icons) == 3 assert server_info.icons == icons @@ -320,37 +319,12 @@ class TestIconTypes: mcp = FastMCP("TestServer", icons=icons) async with Client(mcp) as client: - server_info = client.session.server_info + server_info = client.initialize_result.server_info assert server_info.icons[0].src == "https://example.com/icon.png" assert server_info.icons[0].mime_type is None assert server_info.icons[0].sizes is None -class TestIconTheme: - """Test icon theme support.""" - - @pytest.mark.parametrize("theme", ["light", "dark"]) - async def test_icon_with_theme_round_trips(self, theme: IconTheme): - """Test that an icon's theme survives a client round-trip.""" - icons = [Icon(src="https://example.com/icon.png", theme=theme)] - - mcp = FastMCP("TestServer", icons=icons) - - async with Client(mcp) as client: - server_info = client.session.server_info - assert server_info.icons[0].theme == theme - - async def test_icon_without_theme_is_none(self): - """Test that an icon with no theme specified round-trips as None.""" - icons = [Icon(src="https://example.com/icon.png")] - - mcp = FastMCP("TestServer", icons=icons) - - async with Client(mcp) as client: - server_info = client.session.server_info - assert server_info.icons[0].theme is None - - class TestIconImport: """Test that Icon must be imported from mcp_types.""" diff --git a/tests/server/test_legacy_httpx_errors.py b/tests/server/test_legacy_httpx_errors.py deleted file mode 100644 index 38f9434a0..000000000 --- a/tests/server/test_legacy_httpx_errors.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Compatibility tests for legacy-httpx exceptions raised by user code.""" - -import pytest - -from fastmcp import FastMCP -from fastmcp.exceptions import ResourceError, ToolError - -httpx = pytest.importorskip("httpx", reason="legacy httpx not installed") - - -async def test_legacy_httpx_rate_limit_remains_actionable() -> None: - server = FastMCP("Legacy httpx errors", mask_error_details=True) - - @server.tool - def rate_limited() -> None: - request = httpx.Request("GET", "https://example.com") - response = httpx.Response(429, request=request) - raise httpx.HTTPStatusError("rate limited", request=request, response=response) - - with pytest.raises(ToolError, match="Rate limited by upstream API"): - await server.call_tool("rate_limited", {}) - - -async def test_legacy_httpx_resource_timeout_remains_actionable() -> None: - server = FastMCP("Legacy httpx errors", mask_error_details=True) - - @server.resource("resource://timed-out") - def timed_out() -> str: - request = httpx.Request("GET", "https://example.com") - raise httpx.ReadTimeout("timed out", request=request) - - with pytest.raises(ResourceError, match="Upstream request timed out"): - await server.read_resource("resource://timed-out") diff --git a/tests/server/test_mrtr_guards.py b/tests/server/test_mrtr_guards.py deleted file mode 100644 index 8355bdebe..000000000 --- a/tests/server/test_mrtr_guards.py +++ /dev/null @@ -1,1217 +0,0 @@ -"""Server-side guard-mode multi-round-trip (MRTR, SEP-2322). - -A FastMCP tool may return an ``InputRequiredResult`` as the full result of a -call: the client fulfils the embedded requests (elicitation / sampling / -roots) and calls again — a new, complete request-response cycle — with the -answers on ``ctx.input_responses`` and the echoed opaque ``ctx.request_state``. -This is the SDK's own base "guard" model — the tool runs per round and checks -whether the client's answers are present — with FastMCP mirroring its -semantics exactly. - -These tests exercise the *emission* side (a FastMCP server producing the -``InputRequiredResult`` and being driven to completion) over both in-memory and -HTTP transports, plus the request-state boundary (framework-owned sealing) and -the ≤2025-11-25 era gate. The client-side *answering* path is covered by -``tests/client/client/test_input_required_driver.py``. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Annotated - -import mcp_types -import pytest -from docket import Docket -from mcp.client._input_required import InputRequiredRoundsExceededError -from mcp.server.request_state import RequestStateSecurity -from mcp.shared.exceptions import MCPError -from mcp_types import ElicitRequest, InputRequiredResult -from mcp_types.version import MODERN_PROTOCOL_VERSIONS -from pydantic import Field -from typing_extensions import TypeAliasType - -from fastmcp import Client, Context, FastMCP -from fastmcp.client.elicitation import ElicitResult -from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware -from fastmcp.server.middleware.middleware import Middleware -from fastmcp.tools.base import InputRequiredToolResult, ToolResult -from fastmcp.utilities.tests import run_server_async -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import running_task_server, submit_task, wait_for_task - - -def _elicit(key: str, message: str, field: str) -> ElicitRequest: - """A single-field form elicitation request keyed by ``key``.""" - params = mcp_types.ElicitRequestFormParams( - message=message, - requested_schema={ - "type": "object", - "properties": {field: {"type": "string"}}, - "required": [field], - }, - ) - return ElicitRequest(method="elicitation/create", params=params) - - -def _ask( - request: ElicitRequest, key: str, request_state: str | None -) -> InputRequiredResult: - return InputRequiredResult( - result_type="input_required", - input_requests={key: request}, - request_state=request_state, - ) - - -def _accepted(responses: mcp_types.InputResponses, key: str) -> dict[str, object]: - """The accepted form content for one answered elicitation. - - ``ctx.input_responses`` values are the raw SDK union - (``ElicitResult | CreateMessageResult | ListRootsResult``); a guard tool - narrows to the response type it asked for. - """ - answer = responses[key] - assert isinstance(answer, mcp_types.ElicitResult) - assert answer.content is not None - return dict(answer.content) - - -def two_question_server(**server_kwargs) -> FastMCP: - """A guard tool that asks two dependent questions across three rounds. - - Round 1 (no responses): ask for a destination. - Round 2 (destination answered): ask for a date, carrying the destination - forward through ``request_state`` (a computed value, not re-derived). - Round 3 (date answered): read the carried destination out of - ``request_state`` and finish. - """ - mcp = FastMCP("guard", **server_kwargs) - - @mcp.tool - async def book_flight(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _ask( - _elicit("destination", "Where would you like to fly?", "destination"), - "destination", - request_state=None, - ) - if "destination" in responses: - destination = _accepted(responses, "destination")["destination"] - return _ask( - _elicit("date", f"When to {destination}?", "date"), - "date", - request_state=f"dest={destination}", - ) - assert ctx.request_state is not None - destination = ctx.request_state.split("=", 1)[1] - date = _accepted(responses, "date")["date"] - return f"Booked {destination} on {date}" - - return mcp - - -def _two_answer_handler(asked: list[str]): - async def handler(message, response_type, params, ctx): - asked.append(message) - if "Where" in message: - return ElicitResult( - action="accept", content=response_type(destination="Paris") - ) - return ElicitResult(action="accept", content=response_type(date="2026-08-01")) - - return handler - - -class TestContextProperties: - """`ctx.input_responses` / `ctx.request_state` thin passthroughs.""" - - async def test_none_outside_mrtr_round(self): - """On a plain call (no prior InputRequiredResult), both are None.""" - seen: dict[str, object] = {} - mcp = FastMCP("x") - - @mcp.tool - async def probe(ctx: Context) -> str: - seen["input_responses"] = ctx.input_responses - seen["request_state"] = ctx.request_state - return "ok" - - async with Client(mcp, mode="auto") as client: - await client.call_tool("probe", {}) - - assert seen["input_responses"] is None - assert seen["request_state"] is None - - async def test_none_without_request_context(self): - """Off-request (no bound wire request) the properties are None, not a crash.""" - mcp = FastMCP("x") - async with Context(fastmcp=mcp) as ctx: - assert ctx.input_responses is None - assert ctx.request_state is None - - -# PEP 695 `type X = ...` aliases, built portably (the `type` statement is 3.12+). -# One factors out the whole guard union; one is a lone aliased ask arm; one is a -# composed alias whose union arm hides the guard (`str | _ComposedGuardArm`). -_AliasedGuardUnion = TypeAliasType("_AliasedGuardUnion", str | InputRequiredResult) -_AliasedAskArm = TypeAliasType("_AliasedAskArm", InputRequiredResult) -_ComposedGuardArm = TypeAliasType("_ComposedGuardArm", int | InputRequiredResult) - - -class _InputRequiredSubclass(InputRequiredResult): - """A user subclass of the guard result — still a control signal, not data.""" - - -class TestOutputSchema: - """An `InputRequiredResult` return arm is control flow, not output data, so - it is stripped from output-schema derivation.""" - - def test_union_arm_stripped_keeps_data_schema(self): - from fastmcp.tools.function_tool import FunctionTool - - def book(x: int) -> str | InputRequiredResult: - return "ok" - - tool = FunctionTool.from_function(book) - assert tool.output_schema is not None - # Schema is derived from the residual `str` arm (wrapped as {"result": ...}). - assert tool.output_schema.get("x-fastmcp-wrap-result") is True - - def test_aliased_guard_union_stripped(self): - """A `type Result = str | InputRequiredResult` alias is unwrapped before - stripping, so the ask arm never leaks into the output schema.""" - from fastmcp.tools.function_tool import FunctionTool - - def book(x: int) -> _AliasedGuardUnion: - return "ok" - - tool = FunctionTool.from_function(book) - assert tool.output_schema is not None - assert tool.output_schema.get("x-fastmcp-wrap-result") is True - - def test_aliased_ask_arm_stripped(self): - """A lone aliased arm (`str | AskAlias`) is recognized as a guard signal - and stripped, leaving the data arm's schema.""" - from fastmcp.tools.function_tool import FunctionTool - - def book(x: int) -> str | _AliasedAskArm: - return "ok" - - tool = FunctionTool.from_function(book) - assert tool.output_schema is not None - assert tool.output_schema.get("x-fastmcp-wrap-result") is True - - def test_composed_alias_arm_stripped(self): - """A composed alias arm — `str | Value` where - `Value = int | InputRequiredResult` — is recursively unwrapped so the - hidden guard is stripped, leaving the flattened data arms (`str | int`).""" - from typing import get_args as _get_args - - from fastmcp.tools.function_parsing import _strip_input_required - - stripped = _strip_input_required(str | _ComposedGuardArm) - assert set(_get_args(stripped)) == {str, int} - - def test_bare_input_required_subclass_suppresses_schema(self): - """A bare `InputRequiredResult` *subclass* is subclass-aware suppressed, - matching `run()`'s isinstance handling, so no output schema is emitted - for data the client can never receive.""" - from fastmcp.tools.function_tool import FunctionTool - - def suspend_only(x: int) -> _InputRequiredSubclass: - raise NotImplementedError - - tool = FunctionTool.from_function(suspend_only) - assert tool.output_schema is None - - def test_bare_aliased_input_required_suppresses_schema(self): - """A bare aliased guard return (`-> _AliasedAskArm` where the alias is - `InputRequiredResult`) is de-aliased so downstream suppression applies, - emitting no output schema.""" - from fastmcp.tools.function_tool import FunctionTool - - def suspend_only(x: int) -> _AliasedAskArm: - raise NotImplementedError - - tool = FunctionTool.from_function(suspend_only) - assert tool.output_schema is None - - def test_annotated_bare_guard_returns_suppress_schema(self): - """The wholesale suppression also covers Annotated wrappings of a bare - guard return — including an aliased one — which exact-match replacement - would otherwise miss.""" - from fastmcp.tools.function_tool import FunctionTool - - def annotated_plain(x: int) -> Annotated[InputRequiredResult, Field()]: - raise NotImplementedError - - def annotated_aliased(x: int) -> Annotated[_AliasedAskArm, Field()]: - raise NotImplementedError - - assert FunctionTool.from_function(annotated_plain).output_schema is None - assert FunctionTool.from_function(annotated_aliased).output_schema is None - - def test_bare_input_required_return_suppresses_schema(self): - from fastmcp.tools.function_tool import FunctionTool - - def suspend_only(x: int) -> InputRequiredResult: - raise NotImplementedError - - tool = FunctionTool.from_function(suspend_only) - assert tool.output_schema is None - - -class TestTransformedGuard: - """A guard tool wrapped by TransformedTool must still emit its ask: a - non-object output_schema on the transform reshapes ordinary ToolResults, - but an InputRequiredToolResult (which carries no output data) must pass - through so the wire handler still returns the InputRequiredResult.""" - - async def test_transformed_guard_still_asks(self): - from fastmcp.tools.tool_transform import TransformedTool - - base = two_question_server() - book = await base.get_tool("book_flight") - assert book is not None - - mcp = FastMCP("transformed") - # A non-object output_schema is exactly the transform config that - # rebuilds ordinary ToolResults and would strip the ask. - transformed = TransformedTool.from_tool( - book, name="book", output_schema={"type": "string"} - ) - mcp.add_tool(transformed) - - # The asking (first) round must reach the wire as an InputRequiredResult - # — without the guard it would arrive as an empty terminal result. - async with Client(mcp, mode="auto") as client: - first = await client.session.call_tool( - "book", {}, allow_input_required=True - ) - assert isinstance(first, InputRequiredResult) - assert "destination" in first.input_requests - - async def test_transform_fn_returning_raw_ask_is_wrapped(self): - """A custom transform_fn that returns a raw InputRequiredResult (not a - pre-wrapped InputRequiredToolResult) must still emit the ask — the - transform path wraps it like any tool body.""" - from fastmcp.tools.tool_transform import TransformedTool - - base = two_question_server() - book = await base.get_tool("book_flight") - assert book is not None - - async def transform_fn(**kwargs) -> InputRequiredResult: - return _ask(_elicit("q", "raw ask?", "q"), "q", request_state=None) - - mcp = FastMCP("raw-transform") - transformed = TransformedTool.from_tool( - book, name="raw", transform_fn=transform_fn - ) - mcp.add_tool(transformed) - - async with Client(mcp, mode="auto") as client: - first = await client.session.call_tool("raw", {}, allow_input_required=True) - assert isinstance(first, InputRequiredResult) - assert "q" in first.input_requests - - -class TestInMemoryLoop: - async def test_two_question_loop_completes(self): - """Two dependent asks complete over the in-memory transport; the - client's elicitation handler answers both rounds.""" - asked: list[str] = [] - async with Client( - two_question_server(), - mode="auto", - elicitation_handler=_two_answer_handler(asked), - ) as client: - assert client.protocol_version == "2026-07-28" - result = await client.call_tool("book_flight", {}) - - assert asked == ["Where would you like to fly?", "When to Paris?"] - assert result.data == "Booked Paris on 2026-08-01" - - async def test_input_responses_visible_to_tool(self): - """A guard tool reads the client's answer out of ctx.input_responses.""" - seen: list[object] = [] - mcp = FastMCP("echo") - - @mcp.tool - async def ask_once(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _ask(_elicit("name", "Your name?", "name"), "name", None) - content = _accepted(responses, "name") - seen.append(content) - return f"Hi {content['name']}" - - async def handler(message, response_type, params, ctx): - return ElicitResult(action="accept", content=response_type(name="Ada")) - - async with Client(mcp, mode="auto", elicitation_handler=handler) as client: - result = await client.call_tool("ask_once", {}) - - assert seen == [{"name": "Ada"}] - assert result.data == "Hi Ada" - - async def test_declined_answer_shape(self): - """A client that declines delivers an ElicitResult with action='decline' - and no content into ctx.input_responses (not an error).""" - mcp = FastMCP("decline") - - @mcp.tool - async def ask(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _ask(_elicit("x", "q", "x"), "x", None) - answer = responses["x"] - assert isinstance(answer, mcp_types.ElicitResult) - return f"action={answer.action} content={answer.content}" - - async def decline_handler(message, response_type, params, ctx): - return ElicitResult(action="decline", content=None) - - async with Client( - mcp, mode="auto", elicitation_handler=decline_handler - ) as client: - result = await client.call_tool("ask", {}) - - assert result.data == "action=decline content=None" - - async def test_max_rounds_exceeded_raises(self): - """A two-round guard under max_rounds=1 raises on the client driver.""" - asked: list[str] = [] - async with Client( - two_question_server(), - mode="auto", - elicitation_handler=_two_answer_handler(asked), - input_required_max_rounds=1, - ) as client: - with pytest.raises(InputRequiredRoundsExceededError): - await client.call_tool("book_flight", {}) - - -class TestMountedServer: - async def test_guard_tool_through_parent(self): - """A guard tool on a mounted child completes when called through the - parent's namespaced name — the InputRequiredToolResult forwards through - the provider delegation and is sealed/returned at the parent's wire seam.""" - parent = FastMCP("parent") - parent.mount(two_question_server(), namespace="sub") - - asked: list[str] = [] - async with Client( - parent, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("sub_book_flight", {}) - - assert asked == ["Where would you like to fly?", "When to Paris?"] - assert result.data == "Booked Paris on 2026-08-01" - - -def _modern_proxy(backend: FastMCP) -> FastMCP: - """A FastMCPProxy whose backend client negotiates the modern era, so the - backend can emit an `InputRequiredResult` (SEP-2322) for the proxy to - round-trip rather than drive to completion internally.""" - from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient - - return FastMCPProxy(client_factory=lambda: ProxyClient(backend, mode="auto")) - - -class TestProxyServer: - async def test_guard_tool_round_trips_through_proxy(self): - """A guard tool behind a proxy completes end-to-end: the backend's ask - round-trips into an `InputRequiredToolResult` on the parent (rather than - being driven to completion inside the proxy), so the parent's wire - handler returns it to the real client, and continuation answers forward - back down to the backend.""" - proxy = _modern_proxy(two_question_server()) - - asked: list[str] = [] - async with Client( - proxy, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("book_flight", {}) - - assert asked == ["Where would you like to fly?", "When to Paris?"] - assert result.data == "Booked Paris on 2026-08-01" - - async def test_proxy_parent_middleware_observes_ask(self): - """The proxy round-trip is what lets the parent's own middleware see the - ask as a result — it is not swallowed by driving inside the proxy.""" - observed: list[object] = [] - - class RecordingMiddleware(Middleware): - async def on_call_tool(self, context, call_next): - result = await call_next(context) - observed.append(result) - return result - - proxy = _modern_proxy(two_question_server()) - proxy.add_middleware(RecordingMiddleware()) - - asked: list[str] = [] - async with Client( - proxy, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("book_flight", {}) - - assert result.data == "Booked Paris on 2026-08-01" - assert len(observed) == 3 - assert isinstance(observed[0], InputRequiredToolResult) - assert isinstance(observed[1], InputRequiredToolResult) - assert not isinstance(observed[2], InputRequiredToolResult) - - async def test_guard_round_trips_through_create_proxy_mode_auto(self): - """The standard create_proxy(target, mode="auto") path round-trips a - guard without a hand-built ProxyClient factory. The default stays - handshake-era (which preserves server-initiated push forwarding); modern - proxying is a per-call opt-in because the two eras are mutually - exclusive on a single proxy session.""" - from fastmcp.server import create_proxy - - proxy = create_proxy(two_question_server(), mode="auto") - - asked: list[str] = [] - async with Client( - proxy, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("book_flight", {}) - - assert asked == ["Where would you like to fly?", "When to Paris?"] - assert result.data == "Booked Paris on 2026-08-01" - - async def test_progress_forwards_through_modern_proxy(self): - """A backend tool's `ctx.report_progress()` reaches the caller's progress - handler even when the proxy backend negotiates the modern era — the - modern branch forwards the proxy client's progress handler just like the - legacy `call_tool_mcp` path does.""" - backend = FastMCP("progress-backend") - - @backend.tool - async def report(ctx: Context) -> str: - await ctx.report_progress(progress=1, total=2, message="halfway") - await ctx.report_progress(progress=2, total=2, message="done") - return "ok" - - proxy = _modern_proxy(backend) - - received: list[tuple[float, float | None, str | None]] = [] - - async def progress_handler(progress, total, message): - received.append((progress, total, message)) - - async with Client( - proxy, mode="auto", progress_handler=progress_handler - ) as client: - result = await client.call_tool("report", {}) - - assert result.data == "ok" - assert received == [(1, 2, "halfway"), (2, 2, "done")] - - -@dataclass -class _Person: - name: str - - -def _era_reporting_backend() -> FastMCP: - """A dual-era backend for the mirroring tests. - - Hosts the three-round guard tool (``book_flight``), a tool that reports the - protocol version its own backend session negotiated (``backend_era``), and a - server-initiated elicitation tool (``ask_name``) that only works when the - session is handshake-era, so a single backend proves which era the proxy - mirrored onto it. - """ - mcp = two_question_server() - - @mcp.tool - async def backend_era(ctx: Context) -> str: - rc = ctx.request_context - assert rc is not None - return rc.protocol_version - - @mcp.tool - async def ask_name(ctx: Context) -> str: - result = await ctx.elicit("What is your name?", response_type=_Person) - if result.action == "accept": - assert isinstance(result.data, _Person) - return f"Hello, {result.data.name}!" - return "no name" - - return mcp - - -class TestProxyEraMirroring: - """A proxy created from a non-Client target with no explicit mode mirrors the - front connection's negotiated era onto its backend session, so the whole - chain speaks one era end-to-end.""" - - async def test_modern_front_mirrors_modern_backend(self): - """A modern front through a proxy with NO explicit mode gets a modern - backend session, so a guard tool round-trips end-to-end.""" - from fastmcp.server import create_proxy - - proxy = create_proxy(_era_reporting_backend()) - - asked: list[str] = [] - async with Client( - proxy, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - era = await client.call_tool("backend_era", {}) - result = await client.call_tool("book_flight", {}) - - assert era.data == "2026-07-28" - assert result.data == "Booked Paris on 2026-08-01" - - async def test_legacy_front_mirrors_handshake_backend(self): - """A legacy front through a proxy with NO explicit mode gets a handshake - backend session, so server-initiated elicitation push-forwards through - the proxy to the front client's handler.""" - from fastmcp.server import create_proxy - - proxy = create_proxy(_era_reporting_backend()) - - async def name_handler(message, response_type, params, ctx): - return ElicitResult(action="accept", content=response_type(name="Ada")) - - async with Client( - proxy, mode="legacy", elicitation_handler=name_handler - ) as client: - era = await client.call_tool("backend_era", {}) - greeting = await client.call_tool("ask_name", {}) - - assert era.data not in MODERN_PROTOCOL_VERSIONS - assert greeting.data == "Hello, Ada!" - - async def test_same_proxy_serves_both_eras_without_bleed(self): - """The SAME proxy instance serves a legacy front and a modern front (and - a legacy front again); each gets its matching backend era. This is the - session-cache trap: a backend session pinned to one era must never be - reused across front connections of a different era.""" - from fastmcp.server import create_proxy - - proxy = create_proxy(_era_reporting_backend()) - - async with Client(proxy, mode="legacy") as client: - legacy_era = await client.call_tool("backend_era", {}) - async with Client(proxy, mode="auto") as client: - modern_era = await client.call_tool("backend_era", {}) - async with Client(proxy, mode="legacy") as client: - legacy_again = await client.call_tool("backend_era", {}) - - assert legacy_era.data not in MODERN_PROTOCOL_VERSIONS - assert modern_era.data == "2026-07-28" - assert legacy_again.data not in MODERN_PROTOCOL_VERSIONS - - async def test_explicit_mode_overrides_mirroring(self): - """An explicit ``create_proxy(mode=...)`` pins the backend era regardless - of the front connection's era, overriding mirroring.""" - from fastmcp.server import create_proxy - - proxy = create_proxy(_era_reporting_backend(), mode="auto") - - # Legacy front, but the backend is pinned modern by the explicit mode. - async with Client(proxy, mode="legacy") as client: - era = await client.call_tool("backend_era", {}) - - assert era.data == "2026-07-28" - - -class TestMultiServerConfigEraMirroring: - """A multi-server `MCPConfig` target puts an extra hop between the proxy and - the real backends: `MCPConfigTransport` mounts one proxy per configured - server on a composite router. Setting the era on the outer client alone - would stop at that router, leaving every real backend on its own default - era, so the mirrored era has to reach the mounted legs too. - """ - - @staticmethod - def _config(url: str) -> dict[str, object]: - """Two entries so the transport takes its multi-server composite path.""" - return {"mcpServers": {"a": {"url": url}, "b": {"url": url}}} - - async def test_modern_front_reaches_modern_backends(self): - """A modern front reaches each real backend on a modern session, and a - backend guard tool round-trips end to end across both proxy hops.""" - from fastmcp.server import create_proxy - - async with run_server_async(_era_reporting_backend()) as url: - proxy = create_proxy(self._config(url)) - - asked: list[str] = [] - async with Client( - proxy, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - era = await client.call_tool("a_backend_era", {}) - result = await client.call_tool("a_book_flight", {}) - - assert era.data == "2026-07-28" - assert result.data == "Booked Paris on 2026-08-01" - assert len(asked) == 2 - - async def test_legacy_front_reaches_handshake_backends(self): - """A legacy front reaches each real backend on a handshake session, so - server-initiated elicitation still push-forwards up the whole chain.""" - from fastmcp.server import create_proxy - - async def name_handler(message, response_type, params, ctx): - return ElicitResult(action="accept", content=response_type(name="Ada")) - - async with run_server_async(_era_reporting_backend()) as url: - proxy = create_proxy(self._config(url)) - - async with Client( - proxy, mode="legacy", elicitation_handler=name_handler - ) as client: - era = await client.call_tool("a_backend_era", {}) - greeting = await client.call_tool("a_ask_name", {}) - - assert era.data not in MODERN_PROTOCOL_VERSIONS - assert greeting.data == "Hello, Ada!" - - async def test_explicit_mode_overrides_mirroring(self): - """An explicit ``create_proxy(mode=...)`` pins the era all the way down, - overriding what the front negotiated.""" - from fastmcp.server import create_proxy - - async with run_server_async(_era_reporting_backend()) as url: - proxy = create_proxy(self._config(url), mode="auto") - - async with Client(proxy, mode="legacy") as client: - era = await client.call_tool("a_backend_era", {}) - - assert era.data == "2026-07-28" - - -class TestEraGate: - async def test_legacy_connection_rejects_with_era_error(self): - """Returning an InputRequiredResult on a ≤2025-11-25 connection produces - a clear era error naming 2026-07-28, not a generic 'invalid result'.""" - async with Client(two_question_server(), mode="legacy") as client: - assert client.protocol_version == "2025-11-25" - with pytest.raises(MCPError) as excinfo: - await client.call_tool("book_flight", {}) - - message = str(excinfo.value) - assert "2026-07-28" in message - assert "2025-11-25" in message - assert "InputRequiredResult" in message - - -class TestRequestStateSealing: - """The framework (SDK RequestStateBoundary middleware) owns sealing: a tool - only ever mints/reads plaintext, and the wire value is authenticated.""" - - def _one_shot_server(self) -> FastMCP: - mcp = FastMCP("seal") - - @mcp.tool - async def guard(ctx: Context) -> str | InputRequiredResult: - if ctx.input_responses is None: - return _ask( - _elicit("x", "q", "x"), "x", request_state="PLAINTEXT-STATE" - ) - return f"state={ctx.request_state}" - - return mcp - - async def test_wire_request_state_is_sealed(self): - """The requestState the client receives is the sealed token, never the - plaintext the tool minted.""" - async with Client(self._one_shot_server(), mode="auto") as client: - first = await client.session.call_tool( - "guard", {}, allow_input_required=True - ) - assert isinstance(first, InputRequiredResult) - assert first.request_state is not None - assert first.request_state != "PLAINTEXT-STATE" - # The SDK's AES-256-GCM codec stamps a versioned "v1." prefix. - assert first.request_state.startswith("v1.") - - async def test_tampered_request_state_rejected(self): - """A modified requestState echo fails the boundary's integrity check with - the frozen wire error, before the tool runs.""" - async with Client(self._one_shot_server(), mode="auto") as client: - first = await client.session.call_tool( - "guard", {}, allow_input_required=True - ) - assert isinstance(first, InputRequiredResult) - assert first.request_state is not None - tampered = first.request_state[:-2] + ( - "AA" if not first.request_state.endswith("AA") else "BB" - ) - with pytest.raises(MCPError) as excinfo: - await client.session.call_tool( - "guard", - {}, - input_responses={"x": {"action": "accept", "content": {"x": "v"}}}, - request_state=tampered, - allow_input_required=True, - ) - assert "requestState" in str(excinfo.value) - - async def test_plaintext_round_trips_to_tool(self): - """A valid echo delivers the original plaintext back to the tool.""" - async with Client(self._one_shot_server(), mode="auto") as client: - first = await client.session.call_tool( - "guard", {}, allow_input_required=True - ) - assert isinstance(first, InputRequiredResult) - second = await client.session.call_tool( - "guard", - {}, - input_responses={"x": {"action": "accept", "content": {"x": "v"}}}, - request_state=first.request_state, - allow_input_required=True, - ) - assert isinstance(second, mcp_types.CallToolResult) - assert second.structured_content == {"result": "state=PLAINTEXT-STATE"} - - -class TestMiddlewareInteraction: - """Each MRTR leg is a complete request→response cycle: an - `InputRequiredResult` is the full, legitimate result of that leg, so it - flows back through the middleware chain as an ordinary `ToolResult` - (specifically an `InputRequiredToolResult`). Middleware observes it as a - normal result, not an error and not control flow.""" - - async def test_completes_with_error_and_broad_except_middleware(self): - """An ask is a result, not an error, so error-handling middleware and a - broad ``except Exception`` are simply irrelevant — `call_next` returns - the ask, nothing is raised, and the loop completes normally. - """ - - class BroadCatchMiddleware(Middleware): - async def on_call_tool(self, context, call_next): - # The ask is RETURNED here, never raised, so this except never - # fires on a guard round. - try: - return await call_next(context) - except Exception as e: - raise RuntimeError(f"swallowed: {e}") from e - - server = two_question_server() - server.add_middleware(ErrorHandlingMiddleware()) - server.add_middleware(BroadCatchMiddleware()) - - asked: list[str] = [] - async with Client( - server, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("book_flight", {}) - assert result.data == "Booked Paris on 2026-08-01" - assert len(asked) == 2 - - async def test_middleware_observes_ask_then_final_result_per_leg(self): - """A logging-style recording middleware sees the full chain run on every - leg: the ask (`InputRequiredToolResult`) is the observed result on the - two guard legs, and the terminal string is the result on the last leg. - Three client-visible rounds ⇒ three `on_call_tool` fires.""" - observed: list[object] = [] - - class RecordingMiddleware(Middleware): - async def on_call_tool(self, context, call_next): - result = await call_next(context) - observed.append(result) - return result - - server = two_question_server() - server.add_middleware(RecordingMiddleware()) - - asked: list[str] = [] - async with Client( - server, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("book_flight", {}) - - assert result.data == "Booked Paris on 2026-08-01" - # One on_call_tool fire per leg — three legs, three observations. - assert len(observed) == 3 - # The first two legs each return the ask as their result; middleware can - # identify it by isinstance on the ToolResult subclass. - assert isinstance(observed[0], InputRequiredToolResult) - assert isinstance(observed[1], InputRequiredToolResult) - # Each ask carries the underlying InputRequiredResult unmodified. - assert isinstance(observed[0].input_required, InputRequiredResult) - # The final leg returns the ordinary terminal result, not an ask. - assert isinstance(observed[2], ToolResult) - assert not isinstance(observed[2], InputRequiredToolResult) - assert observed[2].structured_content == { - "result": "Booked Paris on 2026-08-01" - } - - async def test_continuation_leg_is_detectable_from_context(self): - """Middleware can distinguish an initial leg from a continuation leg via - ``context.fastmcp_context.input_responses`` — ``None`` on the first - round, present once the client has answered.""" - input_responses_seen: list[bool] = [] - - class DetectMiddleware(Middleware): - async def on_call_tool(self, context, call_next): - fctx = context.fastmcp_context - assert fctx is not None - input_responses_seen.append(fctx.input_responses is not None) - return await call_next(context) - - server = two_question_server() - server.add_middleware(DetectMiddleware()) - - asked: list[str] = [] - async with Client( - server, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("book_flight", {}) - - assert result.data == "Booked Paris on 2026-08-01" - # Leg 1 has no answers yet; legs 2 and 3 are continuations. - assert input_responses_seen == [False, True, True] - - async def test_continuation_fields_populate_message(self): - """The continuation fields (SEP-2322) appear on ``context.message`` - itself, not only on ``fastmcp_context`` — so middleware branching on the - standard `input_responses` / `request_state` params sees a continuation - round as such rather than as an initial call.""" - responses_seen: list[bool] = [] - state_seen: list[bool] = [] - - class MessageMiddleware(Middleware): - async def on_call_tool(self, context, call_next): - responses_seen.append(context.message.input_responses is not None) - state_seen.append(context.message.request_state is not None) - return await call_next(context) - - server = two_question_server() - server.add_middleware(MessageMiddleware()) - - asked: list[str] = [] - async with Client( - server, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("book_flight", {}) - - assert result.data == "Booked Paris on 2026-08-01" - # Answers arrive on legs 2 and 3; request_state is carried only on leg 3 - # (round 2's ask minted it), so it is absent on legs 1 and 2. - assert responses_seen == [False, True, True] - assert state_seen == [False, False, True] - - -class TestCachingMiddlewareInteraction: - async def test_ask_is_not_cached(self): - """The response cache must never store an ask: two identical first-leg - calls both reach the tool (the second is not served a stale cached - question). A cached ask would replay a stale prompt and skip the tool's - own per-round logic.""" - from fastmcp.server.middleware.caching import ResponseCachingMiddleware - - call_count = {"n": 0} - mcp = FastMCP("cache-guard") - - @mcp.tool - async def ask(ctx: Context) -> str | InputRequiredResult: - if ctx.input_responses is None: - call_count["n"] += 1 - return _ask(_elicit("x", "q", "x"), "x", None) - return "done" - - mcp.add_middleware(ResponseCachingMiddleware()) - - # Two independent first legs (no elicitation handler ⇒ each raises on the - # client driver rather than completing), but both must have reached the - # tool body — proving the ask was not served from cache. - async with Client(mcp, mode="auto") as client: - for _ in range(2): - first = await client.session.call_tool( - "ask", {}, allow_input_required=True - ) - assert isinstance(first, InputRequiredResult) - - assert call_count["n"] == 2 - - async def test_state_only_continuation_final_not_cached(self): - """A state-only round (request_state, no questions) retries with - input_responses=None — request_state alone must mark the continuation, - or its terminal result would be cached under the fresh-call key.""" - from fastmcp.server.middleware.caching import ResponseCachingMiddleware - - body_runs = {"n": 0} - mcp = FastMCP("cache-state-only") - - @mcp.tool - async def staged(ctx: Context) -> str | InputRequiredResult: - body_runs["n"] += 1 - if ctx.request_state is None: - return InputRequiredResult( - result_type="input_required", - input_requests={}, - request_state="stage=1", - ) - return f"done after {ctx.request_state}" - - mcp.add_middleware(ResponseCachingMiddleware()) - - async with Client(mcp, mode="auto") as client: - first = await client.session.call_tool( - "staged", {}, allow_input_required=True - ) - assert isinstance(first, InputRequiredResult) - # The client echoes the (sealed) state with no responses — the - # state-only continuation the guard must recognize. - final = await client.session.call_tool( - "staged", - {}, - request_state=first.request_state, - allow_input_required=True, - ) - assert not isinstance(final, InputRequiredResult) - runs_after_flow = body_runs["n"] - - # A fresh identical call must run the tool again, not be served - # the continuation's cached final. - fresh = await client.session.call_tool( - "staged", {}, allow_input_required=True - ) - assert isinstance(fresh, InputRequiredResult) - - assert body_runs["n"] == runs_after_flow + 1 - - async def test_completed_flow_final_result_not_served_to_fresh_call(self): - """A continuation leg's final result must not enter the cache: its key - is built from name+arguments only, identical to a fresh call's — so a - cached final would be served to the next fresh call, which would then - never be asked the tool's questions.""" - from fastmcp.server.middleware.caching import ResponseCachingMiddleware - - body_runs = {"n": 0} - mcp = FastMCP("cache-flow") - - @mcp.tool - async def ask(ctx: Context) -> str | InputRequiredResult: - body_runs["n"] += 1 - if ctx.input_responses is None: - return _ask(_elicit("x", "q", "x"), "x", None) - return "done" - - mcp.add_middleware(ResponseCachingMiddleware()) - - async def answer(message, response_type, params, ctx): - return ElicitResult(action="accept", content=response_type(x="a")) - - async with Client(mcp, mode="auto", elicitation_handler=answer) as client: - first = await client.call_tool("ask", {}) - assert first.data == "done" - runs_after_first_flow = body_runs["n"] - - # A fresh identical call must run the tool again (ask + answer), - # not be served the previous flow's cached final answer. - second = await client.call_tool("ask", {}) - assert second.data == "done" - - assert body_runs["n"] == runs_after_first_flow + 2 - - -class TestAnnotatedReturns: - async def test_annotated_union_return_strips_guard_arm(self): - """``Annotated[str | InputRequiredResult, ...]`` derives its output - schema from the data arm; the guard arm is stripped inside Annotated.""" - mcp = FastMCP("AnnotatedGuard") - - @mcp.tool - async def greet( - ctx: Context, - ) -> Annotated[str | InputRequiredResult, Field(description="greeting")]: - if ctx.input_responses is None: - return InputRequiredResult( - input_requests={"name": _elicit("name", "Your name?", "name")}, - request_state="", - ) - return "hello" - - tool = await mcp.get_tool("greet") - assert tool is not None - schema = tool.output_schema - assert schema is not None - # Derived from the str arm — not poisoned by the guard arm. - assert "InputRequired" not in str(schema) - - async def test_metadata_on_guard_arm_is_stripped(self): - """When only the guard arm carries metadata - (``str | Annotated[InputRequiredResult, Field(...)]``), it is still - recognized and stripped so the str arm's schema survives.""" - mcp = FastMCP("AnnotatedGuardArm") - - @mcp.tool - async def greet( - ctx: Context, - ) -> str | Annotated[InputRequiredResult, Field(description="suspend")]: - if ctx.input_responses is None: - return InputRequiredResult( - input_requests={"name": _elicit("name", "Your name?", "name")}, - request_state="", - ) - return "hello" - - tool = await mcp.get_tool("greet") - assert tool is not None - schema = tool.output_schema - assert schema is not None - assert "InputRequired" not in str(schema) - - -class TestRequestStateSecurityConfig: - def test_custom_security_without_stable_audience_warns(self, caplog): - """A supplied policy with neither an explicit audience nor a stable - server name warns (shared keys across replicas would stamp per-replica - random audiences) — but constructs, since single-process customization - (e.g. an ephemeral policy with a custom ttl) is legitimate and a policy - object cannot reveal whether its keys are shared.""" - import logging - - with caplog.at_level(logging.WARNING): - FastMCP(request_state_security=RequestStateSecurity(keys=[b"0" * 32])) - assert any("stable audience" in r.message for r in caplog.records) - - # An empty name is falsy → still a random per-replica name, so it must - # warn like an omitted name (not slip through a `name is None` check). - caplog.clear() - with caplog.at_level(logging.WARNING): - FastMCP( - name="", request_state_security=RequestStateSecurity(keys=[b"0" * 32]) - ) - assert any("stable audience" in r.message for r in caplog.records) - - # Single-process customization is allowed (warns, does not raise): - FastMCP(request_state_security=RequestStateSecurity.ephemeral(ttl=30)) - - # Either remedy avoids the warning: - caplog.clear() - with caplog.at_level(logging.WARNING): - FastMCP( - name="Stable", - request_state_security=RequestStateSecurity(keys=[b"0" * 32]), - ) - FastMCP( - request_state_security=RequestStateSecurity( - keys=[b"0" * 32], audience="my-service" - ) - ) - assert not any("stable audience" in r.message for r in caplog.records) - - async def test_explicit_shared_keys_seal_and_complete(self): - """A server configured with explicit shared keys drives a full loop — - the multi-replica configuration (RequestStateSecurity(keys=[...])).""" - key = b"0" * 32 - server = two_question_server( - request_state_security=RequestStateSecurity(keys=[key]) - ) - asked: list[str] = [] - async with Client( - server, mode="auto", elicitation_handler=_two_answer_handler(asked) - ) as client: - result = await client.call_tool("book_flight", {}) - assert result.data == "Booked Paris on 2026-08-01" - - def _sealing_server(self, key: bytes) -> FastMCP: - """A one-round guard that seals a request_state on its first ask, under - an explicit key so state can be replayed across instances.""" - mcp = FastMCP( - "seal-keyed", - request_state_security=RequestStateSecurity(keys=[key]), - ) - - @mcp.tool - async def guard(ctx: Context) -> str | InputRequiredResult: - if ctx.input_responses is None: - return _ask(_elicit("x", "q", "x"), "x", request_state="carried") - return f"state={ctx.request_state}" - - return mcp - - async def test_state_from_a_different_key_is_rejected(self): - """State minted under one key is rejected by a server holding a - different key — the cross-instance isolation shared keys prevent.""" - async with Client(self._sealing_server(b"a" * 32), mode="auto") as client: - first = await client.session.call_tool( - "guard", {}, allow_input_required=True - ) - assert isinstance(first, InputRequiredResult) - state_from_a = first.request_state - assert state_from_a is not None - - async with Client(self._sealing_server(b"b" * 32), mode="auto") as client: - with pytest.raises(MCPError): - await client.session.call_tool( - "guard", - {}, - input_responses={"x": {"action": "accept", "content": {"x": "v"}}}, - request_state=state_from_a, - allow_input_required=True, - ) - - -class TestTaskExecution: - """A guard tool returns an `InputRequiredResult` as its result, which only - makes sense against a live request that can answer the prompt. A detached - background task has no such request, so returning a guard result from a task - is rejected with a clear error rather than silently yielding empty content.""" - - @pytest.fixture - def reset_docket_memory_server(self): - """Force a fresh memory:// Docket server bound to this test's loop.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - async def test_guard_result_from_task_parks_for_input( - self, reset_docket_memory_server - ): - mcp = FastMCP("guard-task") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def book_flight(ctx: Context) -> str | InputRequiredResult: - return _ask( - _elicit("date", "When?", "date"), - key="date", - request_state=None, - ) - - # A function-tool guard is driven as a task by the in-task reentrant - # loop: submitting `book_flight` parks its input request on the poll - # surface (`input_required`), where a client answers it via - # `tasks/update`. The full round-trip lives in - # tests/tasks/server/test_guard_reentrant.py. - async with running_task_server(mcp): - created = await submit_task(mcp, "book_flight", {}) - parked = await wait_for_task( - mcp, - created.task_id, - target_states=frozenset({"input_required"}), - ) - assert parked.status == "input_required" - assert parked.input_requests - - -class TestHttpTransport: - async def test_two_question_loop_over_http(self): - """The full guard loop completes over Streamable HTTP with mode='auto'.""" - asked: list[str] = [] - async with run_server_async(two_question_server()) as url: - async with Client( - StreamableHttpTransport(url), - mode="auto", - elicitation_handler=_two_answer_handler(asked), - ) as client: - assert client.protocol_version == "2026-07-28" - result = await client.call_tool("book_flight", {}) - - assert asked == ["Where would you like to fly?", "When to Paris?"] - assert result.data == "Booked Paris on 2026-08-01" diff --git a/tests/server/test_mrtr_guards_components.py b/tests/server/test_mrtr_guards_components.py deleted file mode 100644 index ad896aa87..000000000 --- a/tests/server/test_mrtr_guards_components.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Guard-mode multi-round-trip for prompts and resources (SEP-2322). - -`InputRequiredResult` is a *result type*, not a `tools/call` feature: any -request can resolve to one. A prompt or resource asks for client input exactly -the way a tool does — return the ask, read `ctx.input_responses` on the round -that follows. - -These tests cover the emission side for prompts, concrete resources, and -resource templates, the 2026-07-28 era gate, and the proxy path, where the ask -must be forwarded to the parent rather than answered inside the proxy (a proxy -has no back-channel to the real user). Tool guards live in -``tests/server/test_mrtr_guards.py``. -""" - -from __future__ import annotations - -import mcp_types -import pytest -from mcp.shared.exceptions import MCPError -from mcp_types import ElicitRequest, InputRequiredResult - -from fastmcp import Client, Context, FastMCP -from fastmcp.client.elicitation import ElicitResult - - -def _elicit(key: str, message: str, field: str) -> ElicitRequest: - """A single-field form elicitation request keyed by ``key``.""" - params = mcp_types.ElicitRequestFormParams( - message=message, - requested_schema={ - "type": "object", - "properties": {field: {"type": "string"}}, - "required": [field], - }, - ) - return ElicitRequest(method="elicitation/create", params=params) - - -def _ask( - request: ElicitRequest, key: str, request_state: str | None -) -> InputRequiredResult: - return InputRequiredResult( - result_type="input_required", - input_requests={key: request}, - request_state=request_state, - ) - - -def _accepted(responses: mcp_types.InputResponses, key: str) -> dict[str, object]: - """The accepted form content for one answered elicitation.""" - answer = responses[key] - assert isinstance(answer, mcp_types.ElicitResult) - assert answer.content is not None - return dict(answer.content) - - -def _modern_proxy(backend: FastMCP) -> FastMCP: - """A proxy whose backend client negotiates the modern era, so the backend - can emit an `InputRequiredResult` for the proxy to round-trip.""" - from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient - - return FastMCPProxy(client_factory=lambda: ProxyClient(backend, mode="auto")) - - -class TestPromptGuard: - """`InputRequiredResult` is a result type, not a tools/call feature, so a - prompt can ask for input the same way a tool does (SEP-2322).""" - - @staticmethod - def _context_prompt_server() -> FastMCP: - mcp = FastMCP("prompt-guard") - - @mcp.prompt - async def summarize(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _ask( - _elicit("context", "What context?", "context"), - key="context", - request_state=None, - ) - return f"Summarizing with {_accepted(responses, 'context')['context']}" - - return mcp - - async def test_prompt_emits_input_required(self): - """The asking round reaches the wire as an InputRequiredResult.""" - async with Client(self._context_prompt_server(), mode="auto") as client: - result = await client.session.get_prompt( - "summarize", allow_input_required=True - ) - - assert isinstance(result, InputRequiredResult) - assert "context" in result.input_requests - - async def test_prompt_completes_with_responses(self): - """Answering the ask renders the prompt on the next round.""" - mcp = self._context_prompt_server() - async with Client(mcp, mode="auto") as client: - ask = await client.session.get_prompt( - "summarize", allow_input_required=True - ) - assert isinstance(ask, InputRequiredResult) - done = await client.session.get_prompt( - "summarize", - input_responses={ - "context": mcp_types.ElicitResult( - action="accept", content={"context": "quarterly report"} - ) - }, - ) - - assert done.messages[0].content.text == ("Summarizing with quarterly report") - - async def test_prompt_guard_rejected_on_handshake_era(self): - """The result type only exists at 2026-07-28, so an older connection - gets the era named rather than a generic invalid-result failure.""" - async with Client(self._context_prompt_server(), mode="legacy") as client: - with pytest.raises(MCPError, match="2026-07-28"): - await client.session.get_prompt("summarize") - - -class TestResourceGuard: - """Resources and templates ask for input the same way tools and prompts do.""" - - @staticmethod - def _resource_server() -> FastMCP: - mcp = FastMCP("resource-guard") - - @mcp.resource("data://report") - async def report(ctx: Context) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _ask( - _elicit("context", "Which quarter?", "context"), - key="context", - request_state=None, - ) - return f"Report for {_accepted(responses, 'context')['context']}" - - @mcp.resource("data://report/{section}") - async def section_report( - section: str, ctx: Context - ) -> str | InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _ask( - _elicit("context", f"Which quarter for {section}?", "context"), - key="context", - request_state=None, - ) - quarter = _accepted(responses, "context")["context"] - return f"{section} for {quarter}" - - return mcp - - async def test_resource_emits_input_required(self): - async with Client(self._resource_server(), mode="auto") as client: - result = await client.session.read_resource( - "data://report", allow_input_required=True - ) - - assert isinstance(result, InputRequiredResult) - assert "context" in result.input_requests - - async def test_resource_completes_with_responses(self): - async with Client(self._resource_server(), mode="auto") as client: - done = await client.session.read_resource( - "data://report", - input_responses={ - "context": mcp_types.ElicitResult( - action="accept", content={"context": "Q3"} - ) - }, - ) - - assert done.contents[0].text == "Report for Q3" - - async def test_resource_template_emits_input_required(self): - """Templates share the converter, so the ask survives there too.""" - async with Client(self._resource_server(), mode="auto") as client: - result = await client.session.read_resource( - "data://report/revenue", allow_input_required=True - ) - - assert isinstance(result, InputRequiredResult) - assert "context" in result.input_requests - - async def test_resource_guard_rejected_on_handshake_era(self): - async with Client(self._resource_server(), mode="legacy") as client: - with pytest.raises(MCPError, match="2026-07-28"): - await client.session.read_resource("data://report") - - -class TestProxyForwarding: - """A proxy forwards a backend guard's ask instead of answering it.""" - - async def test_guard_prompt_round_trips_through_proxy(self): - """A guard prompt behind a proxy surfaces its ask instead of the proxy - trying to answer it. The proxy has no back-channel to the real user, so - driving the ask internally fails with "Elicitation not supported".""" - backend = TestPromptGuard._context_prompt_server() - - async def answer(message, response_type, params, ctx): - return ElicitResult( - action="accept", content=response_type(context="quarterly report") - ) - - async with Client( - _modern_proxy(backend), mode="auto", elicitation_handler=answer - ) as client: - result = await client.get_prompt("summarize") - - assert result.messages[0].content.text == "Summarizing with quarterly report" - - async def test_guard_resource_round_trips_through_proxy(self): - """Concrete resources and templates forward the ask the same way.""" - backend = TestResourceGuard._resource_server() - - async def answer(message, response_type, params, ctx): - return ElicitResult(action="accept", content=response_type(context="Q3")) - - async with Client( - _modern_proxy(backend), mode="auto", elicitation_handler=answer - ) as client: - direct = await client.read_resource("data://report") - templated = await client.read_resource("data://report/revenue") - - assert direct[0].text == "Report for Q3" - assert templated[0].text == "revenue for Q3" diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py index 2172b7156..ca9a8ac41 100644 --- a/tests/server/test_protocol_eras.py +++ b/tests/server/test_protocol_eras.py @@ -27,11 +27,15 @@ from mcp.client import Client as SDKClient from mcp.client.session import ClientRequestContext from mcp.server import Server as LowLevelServer from mcp.shared.exceptions import MCPError +from mcp_types import methods +from mcp_types.version import ( + HANDSHAKE_PROTOCOL_VERSIONS, + MODERN_PROTOCOL_VERSIONS, +) from pydantic import FileUrl from fastmcp import Client as FastMCPClient -from fastmcp import Context, FastMCP -from fastmcp.exceptions import PromptError, ResourceError +from fastmcp import Context, FastMCP, settings from fastmcp.server.elicitation import AcceptedElicitation from fastmcp.server.middleware import Middleware @@ -172,7 +176,6 @@ async def test_legacy_uses_initialize_handshake(dual_era_server): """ async with SDKClient(_server(dual_era_server), mode="legacy") as client: assert client.protocol_version == "2025-11-25" - assert client.server_info is not None assert client.server_info.name == "dual-era" @@ -183,16 +186,14 @@ async def test_auto_negotiates_modern_via_discover(dual_era_server): async with SDKClient(_server(dual_era_server), mode="auto") as client: assert client.protocol_version == "2026-07-28" # server/discover carries identity, unlike the synthesized pin below. - assert client.server_info is not None assert client.server_info.name == "dual-era" assert client.server_capabilities is not None async def test_pinned_modern_adopts_without_probe(dual_era_server): """Pinning `mode='2026-07-28'` adopts the version directly. With no - `prior_discover`, the SDK synthesizes a minimal DiscoverResult that carries - no identity, so server_info is absent even though the protocol version is - modern. + `prior_discover`, the SDK synthesizes a minimal DiscoverResult, so + server_info is empty even though the protocol version is modern. Characterization of the SDK's synthesize-discover path (mcp.client.client `_synthesize_discover`): a pin without prior_discover trades identity for @@ -200,7 +201,7 @@ async def test_pinned_modern_adopts_without_probe(dual_era_server): """ async with SDKClient(_server(dual_era_server), mode="2026-07-28") as client: assert client.protocol_version == "2026-07-28" - assert client.server_info is None + assert client.server_info.name == "" # --------------------------------------------------------------------------- @@ -218,6 +219,16 @@ def push_server() -> FastMCP: assert isinstance(result, AcceptedElicitation) return f"elicited {result.data}" + @mcp.tool + async def do_sample(ctx: Context) -> str: + result = await ctx.sample("hello") + return f"sampled {result.text}" + + @mcp.tool + async def do_list_roots(ctx: Context) -> str: + roots = await ctx.list_roots() + return f"roots {[str(r.uri) for r in roots]}" + @mcp.tool async def do_log(ctx: Context) -> str: await ctx.info("a log line") @@ -257,12 +268,31 @@ async def test_elicit_works_on_legacy(push_server): assert _texts(result.content) == ["elicited 7"] +async def test_sample_works_on_legacy(push_server): + async with SDKClient( + _server(push_server), mode="legacy", sampling_callback=_sampling_cb + ) as client: + result = await client.call_tool("do_sample", {}) + assert result.is_error is False + assert _texts(result.content) == ["sampled sampled-text"] + + +async def test_list_roots_works_on_legacy(push_server): + async with SDKClient( + _server(push_server), mode="legacy", list_roots_callback=_roots_cb + ) as client: + result = await client.call_tool("do_list_roots", {}) + assert result.is_error is False + assert _texts(result.content) == ["roots ['file:///tmp']"] + + @pytest.mark.parametrize("mode", MODERN_MODES) -async def test_elicit_degrades_on_modern(push_server, mode): - """Elicitation is a server-initiated request, removed at 2026-07-28 - (SEP-2577), so a tool that uses it must degrade to a surfaced error rather - than hang or crash the connection. The connection survives: a subsequent - normal call still works. +@pytest.mark.parametrize("tool", ["do_elicit", "do_sample", "do_list_roots"]) +async def test_push_features_degrade_on_modern(push_server, mode, tool): + """Server-initiated requests (elicitation/sampling/roots) are removed at + 2026-07-28 (SEP-2577), so a tool that uses them must degrade to a surfaced + error rather than hang or crash the connection. This asserts the + degradation happens and reaches the caller as an isError result. """ async with SDKClient( _server(push_server), @@ -271,46 +301,230 @@ async def test_elicit_degrades_on_modern(push_server, mode): sampling_callback=_sampling_cb, list_roots_callback=_roots_cb, ) as client: - result = await client.call_tool("do_elicit", {}) + result = await client.call_tool(tool, {}) assert result.is_error is True + # A subsequent normal call still works: the connection survived the + # per-request failure rather than tearing down the whole session. log_result = await client.call_tool("do_log", {}) assert log_result.is_error is False -async def test_elicit_degradation_message_is_clear_on_modern(push_server): - """FastMCP era-gates elicit: on a 2026-07-28 connection it raises a clear, - era-aware error before hitting the wire, instead of the SDK's opaque - 'Method not found' (sdk-feedback.md #10). +async def test_list_roots_degradation_message_is_clear_on_modern(push_server): + """`ctx.list_roots()` sends with no related_request_id, so the SDK selects + the connection's no-back-channel outbound and raises the self-explanatory + NoBackChannelError. This is the *good* degradation message and we assert it. + """ + async with SDKClient(_server(push_server), mode="2026-07-28") as client: + result = await client.call_tool("do_list_roots", {}) + assert result.is_error is True + message = " ".join(_texts(result.content)).lower() + assert "back-channel" in message and "server-initiated" in message + + +@pytest.mark.parametrize("tool", ["do_elicit", "do_sample"]) +async def test_elicit_sample_degradation_message_is_clear_on_modern(push_server, tool): + """FastMCP era-gates elicit/sample: on a 2026-07-28 connection they raise a + clear, era-aware error before hitting the wire, instead of the SDK's opaque + 'Method not found' (sdk-feedback.md #10). Both messages name the removed + server-initiated capability so the caller knows why the request degraded. """ async with SDKClient( _server(push_server), mode="2026-07-28", elicitation_callback=_accept_elicit, + sampling_callback=_sampling_cb, ) as client: - result = await client.call_tool("do_elicit", {}) + result = await client.call_tool(tool, {}) + assert result.is_error is True + message = " ".join(_texts(result.content)).lower() + assert "server-initiated" in message + + +# --------------------------------------------------------------------------- +# 3a-bis. Server-configured sampling handler answers WITHOUT the client +# back-channel, so ctx.sample()/ctx.sample_step() must keep working on modern +# connections. The era-gate only fires when nothing can serve the request. +# --------------------------------------------------------------------------- + + +def _handler_server(behavior) -> FastMCP: + """A server whose sampling is answered by a server-side handler.""" + + def sampling_handler(messages, params, ctx) -> str: + return "handler-answer" + + mcp = FastMCP("handler", sampling_handler=sampling_handler) + if behavior is not None: + mcp.sampling_handler_behavior = behavior + + @mcp.tool + async def do_sample(ctx: Context) -> str: + result = await ctx.sample("hello") + return f"sampled {result.text}" + + @mcp.tool + async def do_sample_step(ctx: Context) -> str: + step = await ctx.sample_step("hello") + return f"stepped {step.text}" + + return mcp + + +@pytest.mark.parametrize("mode", MODERN_MODES) +@pytest.mark.parametrize("behavior", ["always", "fallback"]) +@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"]) +async def test_server_sampling_handler_works_on_modern(mode, behavior, method): + """A server-side sampling handler answers entirely server-side, so it works + on modern (2026-07-28) connections regardless of behavior. The era-gate must + NOT block these — nothing touches the removed client back-channel. Crucially, + 'fallback' must go straight to the handler (no bare client-attempt failure).""" + server = _handler_server(behavior) + async with SDKClient(_server(server), mode=mode) as client: + result = await client.call_tool(method, {}) + assert result.is_error is False + assert "handler-answer" in " ".join(_texts(result.content)) + + +@pytest.mark.parametrize("behavior", ["always", "fallback"]) +@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"]) +async def test_server_sampling_handler_works_on_legacy(behavior, method): + """Handshake-era behavior is unchanged: the server-side handler still answers + on legacy connections.""" + server = _handler_server(behavior) + async with SDKClient(_server(server), mode="legacy") as client: + result = await client.call_tool(method, {}) + assert result.is_error is False + assert "handler-answer" in " ".join(_texts(result.content)) + + +@pytest.mark.parametrize("mode", MODERN_MODES) +@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"]) +async def test_sampling_without_handler_still_era_gated_on_modern( + push_server, mode, method +): + """With no server-side handler configured, the request would hit the removed + client back-channel, so the clear era error still fires on modern.""" + # push_server only defines do_sample; add a do_sample_step twin inline. + mcp = FastMCP("no-handler") + + @mcp.tool + async def do_sample(ctx: Context) -> str: + result = await ctx.sample("hello") + return f"sampled {result.text}" + + @mcp.tool + async def do_sample_step(ctx: Context) -> str: + step = await ctx.sample_step("hello") + return f"stepped {step.text}" + + async with SDKClient( + _server(mcp), mode=mode, sampling_callback=_sampling_cb + ) as client: + result = await client.call_tool(method, {}) assert result.is_error is True assert "server-initiated" in " ".join(_texts(result.content)).lower() # --------------------------------------------------------------------------- -# 3a-bis. Sampling and roots are not in the server API at all +# 3b. Sampling deprecation warning (SEP-2577): ctx.sample/ctx.sample_step warn # --------------------------------------------------------------------------- -@pytest.mark.parametrize("name", ["sample", "sample_step", "list_roots"]) -def test_removed_server_initiated_methods_are_absent(name): - """FastMCP 4 targets the modern protocol, so the capabilities SEP-2577 - removed are not in the server-authoring API — not deprecated, not era-gated, - absent. A server that calls them fails at attribute lookup, in every era. - """ - assert not hasattr(Context, name) +@pytest.fixture +def reset_sample_warn_flag(): + """Reset the process-wide warn-once flag so a warning can be observed.""" + import fastmcp.server.context as context_module + + original = set(context_module._sample_deprecation_warned) + context_module._sample_deprecation_warned.clear() + try: + yield + finally: + context_module._sample_deprecation_warned.clear() + context_module._sample_deprecation_warned.update(original) -@pytest.mark.parametrize("kwarg", ["sampling_handler", "sampling_handler_behavior"]) -def test_server_sampling_handler_kwargs_are_rejected(kwarg): - """The server-side sampling handler existed only to answer `ctx.sample()`.""" - with pytest.raises(TypeError, match="SEP-2577"): - FastMCP("gone", **{kwarg: None}) # ty: ignore[invalid-argument-type] +@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"]) +async def test_sampling_emits_deprecation_warning(reset_sample_warn_flag, method): + """`ctx.sample()` and `ctx.sample_step()` emit a FastMCPDeprecationWarning + naming SEP-2577 and the server-side-LLM migration path.""" + from fastmcp.exceptions import FastMCPDeprecationWarning + + mcp = FastMCP("warn") + + @mcp.tool + async def do_sample(ctx: Context) -> str: + await ctx.sample("hello") + return "ok" + + @mcp.tool + async def do_sample_step(ctx: Context) -> str: + await ctx.sample_step("hello") + return "ok" + + with pytest.warns(FastMCPDeprecationWarning, match="SEP-2577"): + async with SDKClient( + _server(mcp), mode="legacy", sampling_callback=_sampling_cb + ) as client: + await client.call_tool(method, {}) + + +async def test_sampling_deprecation_warning_fires_once_per_process( + reset_sample_warn_flag, +): + """The deprecation warning is warn-once: a second sample call in the same + process does not re-warn.""" + from fastmcp.exceptions import FastMCPDeprecationWarning + + mcp = FastMCP("warn-once") + + @mcp.tool + async def do_sample(ctx: Context) -> str: + await ctx.sample("hello") + return "ok" + + with pytest.warns(FastMCPDeprecationWarning): + async with SDKClient( + _server(mcp), mode="legacy", sampling_callback=_sampling_cb + ) as client: + await client.call_tool("do_sample", {}) + + import warnings as _warnings + + with _warnings.catch_warnings(): + _warnings.simplefilter("error", FastMCPDeprecationWarning) + async with SDKClient( + _server(mcp), mode="legacy", sampling_callback=_sampling_cb + ) as client: + result = await client.call_tool("do_sample", {}) + assert result.is_error is False + + +async def test_sampling_deprecation_warning_suppressible_via_settings( + reset_sample_warn_flag, monkeypatch +): + """Setting `deprecation_warnings=False` suppresses the sampling warning, + matching the house pattern for every other FastMCP deprecation.""" + import warnings as _warnings + + from fastmcp.exceptions import FastMCPDeprecationWarning + + monkeypatch.setattr(settings, "deprecation_warnings", False) + + mcp = FastMCP("no-warn") + + @mcp.tool + async def do_sample(ctx: Context) -> str: + await ctx.sample("hello") + return "ok" + + with _warnings.catch_warnings(): + _warnings.simplefilter("error", FastMCPDeprecationWarning) + async with SDKClient( + _server(mcp), mode="legacy", sampling_callback=_sampling_cb + ) as client: + result = await client.call_tool("do_sample", {}) + assert result.is_error is False @pytest.mark.parametrize("mode", MODERN_MODES) @@ -328,6 +542,117 @@ async def test_logging_notification_still_flows_on_modern(push_server, mode): assert _texts(result.content) == ["logged"] +# --------------------------------------------------------------------------- +# 4. Tasks: submission + tasks/get across the eras the _sdk_patches shim covers +# --------------------------------------------------------------------------- + + +@pytest.fixture +def task_server() -> FastMCP: + mcp = FastMCP("tasks") + + @mcp.tool(task=True) + async def slow_add(a: int, b: int) -> int: + return a + b + + return mcp + + +async def test_task_submission_and_get_on_legacy_latest(task_server): + """Legacy-latest (2025-11-25): a task-augmented tools/call returns a + CreateTaskResult and tasks/get resolves it. This exercises the + _sdk_patches registry-widening shim at the 2025-11-25 tools/call surface. + + Driven with the FastMCP client because the v2 SDK client's call_tool has no + `task=` parameter (verified: mcp.client.session.ClientSession.call_tool + exposes no task metadata arg) — see item below. + """ + async with FastMCPClient(task_server) as client: + assert client.initialize_result is not None + assert client.initialize_result.protocol_version == "2025-11-25" + + task = await client.call_tool("slow_add", {"a": 2, "b": 3}, task=True) + assert task.task_id + assert not task.returned_immediately + + await task.wait(timeout=3.0) + result = await task.result() + assert result.data == 5 + + +@pytest.mark.xfail( + strict=True, + reason=( + "The v2 SDK high-level client (mcp.client.Client) and ClientSession " + "expose no `task=` parameter on call_tool, so a task-augmented " + "tools/call cannot be submitted through it at any era; a hand-built " + "raw CallToolRequest does not drive FastMCP's task path either. On " + "2026-07-28 tasks moved to the io.modelcontextprotocol/tasks extension " + "and CreateTaskResult is not part of the tools/call union, so the " + "_sdk_patches shim intentionally does not widen the modern row " + "(sdk-feedback.md #1). Remove once the SDK client supports task " + "submission." + ), +) +async def test_task_submission_on_modern(task_server): + async with SDKClient(_server(task_server), mode="2026-07-28") as client: + params = types.CallToolRequestParams( + name="slow_add", + arguments={"a": 1, "b": 2}, + task=types.TaskMetadata(ttl=60000), + ) + result = await client.session.send_request( + types.CallToolRequest(params=params), types.CreateTaskResult + ) + assert isinstance(result, types.CreateTaskResult) + + +# --------------------------------------------------------------------------- +# 4b. _sdk_patches registry gating: the SEP-1686 task shim widens ONLY the +# handshake-era rows and leaves the 2026-07-28 (extension-era) rows untouched. +# --------------------------------------------------------------------------- + + +def test_task_shim_widens_handshake_tools_call_rows(): + """Every handshake-era tools/call row gains a CreateTaskResult arm.""" + from fastmcp._sdk_patches import get_union_arms + + for version in HANDSHAKE_PROTOCOL_VERSIONS: + row = methods.SERVER_RESULTS[("tools/call", version)] + assert types.CreateTaskResult in get_union_arms(row), version + + +def test_task_shim_does_not_touch_modern_tools_call_row(): + """The 2026-07-28 tools/call row stays the unpatched MRTR union: tasks are + the io.modelcontextprotocol/tasks extension there, so CreateTaskResult must + not be injected.""" + from fastmcp._sdk_patches import get_union_arms + + row = methods.SERVER_RESULTS[("tools/call", "2026-07-28")] + arms = get_union_arms(row) + assert types.CreateTaskResult not in arms + # Unchanged from the SDK default: the 2026 mutually-recursive tool result + # (CallToolResult | InputRequiredResult), keyed by the version-specific types. + arm_names = {arm.__name__ for arm in arms} + assert arm_names == {"CallToolResult", "InputRequiredResult"} + + +@pytest.mark.parametrize( + "task_method", + ["tasks/get", "tasks/result", "tasks/list", "tasks/cancel"], +) +def test_task_shim_registers_tasks_rows_only_for_handshake_eras(task_method): + """tasks/* result rows exist for handshake-era versions and are absent for + the modern (extension) era.""" + for version in HANDSHAKE_PROTOCOL_VERSIONS: + assert (task_method, version) in methods.SERVER_RESULTS, (task_method, version) + for version in MODERN_PROTOCOL_VERSIONS: + assert (task_method, version) not in methods.SERVER_RESULTS, ( + task_method, + version, + ) + + # --------------------------------------------------------------------------- # 5. Sessionless safety: session-id-keyed paths must not crash on 2026 in-memory # --------------------------------------------------------------------------- @@ -356,17 +681,23 @@ async def test_session_id_access_does_not_crash_on_modern(sessionless_server, mo @pytest.mark.parametrize("mode", MODERN_MODES) -async def test_set_logging_level_is_era_gated_on_modern(sessionless_server, mode): - """`logging/setLevel` asks a server to remember a level for the session, and - the modern era has no session — the method is absent from its registry. The - FastMCP client says so plainly instead of no-opping or surfacing the SDK's - opaque "Method not found", and the connection stays usable afterward. +async def test_set_logging_level_does_not_crash_on_modern(sessionless_server, mode): + """logging/setLevel is a session-id-keyed, deprecated-at-2026 operation. + On a sessionless modern in-memory connection it must degrade cleanly (either + succeed as a no-op or raise a surfaced MCPError) rather than crash the + connection. Characterization: capture whichever the current contract is. """ - async with FastMCPClient(sessionless_server, mode=mode) as client: - with pytest.raises(RuntimeError, match="2026-07-28"): - await client.set_logging_level("debug") + async with SDKClient(_server(sessionless_server), mode=mode) as client: + outcome: str + try: + await client.set_logging_level("debug") # ty: ignore[deprecated] + outcome = "ok" + except MCPError: + outcome = "mcperror" + # Either way the connection is still usable afterward. result = await client.call_tool("read_session_id", {}) assert result.is_error is False + assert outcome in {"ok", "mcperror"} # --------------------------------------------------------------------------- @@ -401,90 +732,3 @@ async def test_middleware_runs_on_both_eras(): # One invocation observed from each era. assert counter.count == 2 - - -# --------------------------------------------------------------------------- -# Resource / prompt handler errors must survive both eras -# --------------------------------------------------------------------------- - - -@pytest.fixture -def erroring_server() -> FastMCP: - """A server whose resource and prompt handlers raise FastMCP errors.""" - mcp = FastMCP("erroring") - - @mcp.resource("data://boom") - def boom() -> str: - raise ResourceError("resource detail marker") - - @mcp.resource("data://items/{item_id}") - def item(item_id: int) -> str: - return f"item {item_id}" - - @mcp.prompt - def explode() -> str: - raise PromptError("prompt detail marker") - - return mcp - - -@pytest.mark.parametrize("mode", ALL_MODES) -async def test_resource_error_message_reaches_client( - erroring_server: FastMCP, mode: str -) -> None: - """A ResourceError's message must reach the wire on every era. - - The modern runner masks any handler exception that is not an MCPError or a - ValidationError as a generic "Internal server error", so a ResourceError - that escapes the handler becomes indistinguishable from a server bug. - """ - async with FastMCPClient(erroring_server, mode=mode) as client: - with pytest.raises(MCPError) as exc_info: - await client.read_resource("data://boom") - - assert "resource detail marker" in str(exc_info.value) - - -@pytest.mark.parametrize("mode", ALL_MODES) -async def test_prompt_error_message_reaches_client( - erroring_server: FastMCP, mode: str -) -> None: - """A PromptError's message must reach the wire on every era.""" - async with FastMCPClient(erroring_server, mode=mode) as client: - with pytest.raises(MCPError) as exc_info: - await client.get_prompt("explode") - - assert "prompt detail marker" in str(exc_info.value) - - -@pytest.mark.parametrize("mode", ALL_MODES) -async def test_resource_template_conversion_error_reaches_client( - erroring_server: FastMCP, mode: str -) -> None: - """A bad template argument is a client-input error, not a server fault. - - This is the path that originally exposed the masking: converting - ``item_id`` to an int fails, and the resulting error must name the problem - rather than surface as a generic internal error. - """ - async with FastMCPClient(erroring_server, mode=mode) as client: - with pytest.raises(MCPError) as exc_info: - await client.read_resource("data://items/not-an-int") - - assert "Internal server error" not in str(exc_info.value) - - -@pytest.mark.parametrize("mode", ALL_MODES) -async def test_resource_error_masked_when_masking_enabled(mode: str) -> None: - """Masking still applies: resources leak no more than tools already do.""" - mcp = FastMCP("masked", mask_error_details=True) - - @mcp.resource("data://boom") - def boom() -> str: - raise ValueError("secret internal detail") - - async with FastMCPClient(mcp, mode=mode) as client: - with pytest.raises(MCPError) as exc_info: - await client.read_resource("data://boom") - - assert "secret internal detail" not in str(exc_info.value) diff --git a/tests/server/test_server_docket.py b/tests/server/test_server_docket.py index bd4834b55..7d0b7f5c0 100644 --- a/tests/server/test_server_docket.py +++ b/tests/server/test_server_docket.py @@ -3,29 +3,17 @@ import asyncio from contextlib import asynccontextmanager -import pytest from docket import Docket from docket.worker import Worker -from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker from fastmcp import FastMCP from fastmcp.client import Client +from fastmcp.dependencies import CurrentDocket, CurrentWorker from fastmcp.server.dependencies import get_context -from fastmcp_tasks import TasksExtension HUZZAH = "huzzah!" -@pytest.fixture(autouse=True) -def reset_docket_memory_server(): - """Force a fresh memory:// Docket server bound to each test's event loop.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - async def test_docket_not_initialized_without_task_components(): """Docket is only initialized when task-enabled components exist.""" mcp = FastMCP("test-server") @@ -35,9 +23,10 @@ async def test_docket_not_initialized_without_task_components(): return "no docket needed" async with Client(mcp) as client: - # Without a task=True tool, the lifespan never takes the Docket branch. - assert mcp.docket is None + # Docket should not be initialized + assert mcp._docket is None + # Regular tools still work result = await client.call_tool("regular_tool", {}) assert result.data == "no docket needed" @@ -45,9 +34,8 @@ async def test_docket_not_initialized_without_task_components(): async def test_current_docket(): """CurrentDocket dependency provides access to Docket instance.""" mcp = FastMCP("test-server") - mcp.add_extension(TasksExtension()) - # A task-enabled component makes the lifespan start Docket. + # Need a task-enabled component to trigger Docket initialization @mcp.tool(task=True) async def _trigger_docket() -> str: return "trigger" @@ -65,8 +53,8 @@ async def test_current_docket(): async def test_current_worker(): """CurrentWorker dependency provides access to Worker instance.""" mcp = FastMCP("test-server") - mcp.add_extension(TasksExtension()) + # Need a task-enabled component to trigger Docket initialization @mcp.tool(task=True) async def _trigger_docket() -> str: return "trigger" @@ -89,8 +77,8 @@ async def test_worker_executes_background_tasks(): """Verify that the Docket Worker is running and executes tasks.""" task_completed = asyncio.Event() mcp = FastMCP("test-server") - mcp.add_extension(TasksExtension()) + # Need a task-enabled component to trigger Docket initialization @mcp.tool(task=True) async def _trigger_docket() -> str: return "trigger" @@ -119,12 +107,69 @@ async def test_worker_executes_background_tasks(): await asyncio.wait_for(task_completed.wait(), timeout=2.0) +async def test_current_docket_in_resource(): + """CurrentDocket works in resources.""" + mcp = FastMCP("test-server") + + # Need a task-enabled component to trigger Docket initialization + @mcp.tool(task=True) + async def _trigger_docket() -> str: + return "trigger" + + @mcp.resource("docket://info") + def get_docket_info(docket: Docket = CurrentDocket()) -> str: + assert isinstance(docket, Docket) + return HUZZAH + + async with Client(mcp) as client: + result = await client.read_resource("docket://info") + assert HUZZAH in str(result) + + +async def test_current_docket_in_prompt(): + """CurrentDocket works in prompts.""" + mcp = FastMCP("test-server") + + # Need a task-enabled component to trigger Docket initialization + @mcp.tool(task=True) + async def _trigger_docket() -> str: + return "trigger" + + @mcp.prompt() + def task_prompt(task_type: str, docket: Docket = CurrentDocket()) -> str: + assert isinstance(docket, Docket) + return HUZZAH + + async with Client(mcp) as client: + result = await client.get_prompt("task_prompt", {"task_type": "background"}) + assert HUZZAH in str(result) + + +async def test_current_docket_in_resource_template(): + """CurrentDocket works in resource templates.""" + mcp = FastMCP("test-server") + + # Need a task-enabled component to trigger Docket initialization + @mcp.tool(task=True) + async def _trigger_docket() -> str: + return "trigger" + + @mcp.resource("docket://tasks/{task_id}") + def get_task_status(task_id: str, docket: Docket = CurrentDocket()) -> str: + assert isinstance(docket, Docket) + return HUZZAH + + async with Client(mcp) as client: + result = await client.read_resource("docket://tasks/123") + assert HUZZAH in str(result) + + async def test_concurrent_calls_maintain_isolation(): """Multiple concurrent calls each get the same Docket instance.""" mcp = FastMCP("test-server") - mcp.add_extension(TasksExtension()) docket_ids = [] + # Need a task-enabled component to trigger Docket initialization @mcp.tool(task=True) async def _trigger_docket() -> str: return "trigger" @@ -161,8 +206,8 @@ async def test_user_lifespan_still_works_with_docket(): yield {"custom_data": "test_value"} mcp = FastMCP("test-server", lifespan=custom_lifespan) - mcp.add_extension(TasksExtension()) + # Need a task-enabled component to trigger Docket initialization @mcp.tool(task=True) async def _trigger_docket() -> str: return "trigger" diff --git a/tests/server/test_session_provider.py b/tests/server/test_session_provider.py deleted file mode 100644 index a67af5a34..000000000 --- a/tests/server/test_session_provider.py +++ /dev/null @@ -1,569 +0,0 @@ -"""End-to-end tests for the two session-state patterns and `SessionProvider`. - -Covers the injected `session: UserSession` per-user pattern, the explicit -`session_id: SessionId` argument pattern (with its create-then-validate -lifecycle), and the `SessionProvider` that supplies the `create_session` / -`end_session` lifecycle tools. The schema, registration, and lifecycle paths run -through an in-memory `Client`; the principal-isolation cases drive the tool -through its full injection + storage path under a simulated authenticated -principal. -""" - -import re -from collections.abc import Iterator -from contextlib import contextmanager -from typing import Any -from uuid import UUID - -import pytest -from mcp.server.auth.middleware.auth_context import auth_context_var -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken as SDKAccessToken - -from fastmcp import Client, FastMCP -from fastmcp.exceptions import ToolError -from fastmcp.server.context import Context -from fastmcp.server.dependencies import get_session -from fastmcp.server.sessions import ( - SESSION_ID_DESCRIPTION, - InvalidSession, - SessionId, - SessionProvider, - UserSession, -) -from fastmcp.tools.base import ToolResult - - -def result_value(result: ToolResult) -> Any: - """The `"result"` field of a direct `Tool.run()` call's structured content. - - `structured_content` is `dict | None` on `ToolResult` (a bare function tool - always populates it, but the type isn't narrowed by construction), so this - asserts it is present before indexing. - """ - assert result.structured_content is not None - return result.structured_content["result"] - - -def make_token(*, subject: str = "user-a") -> SDKAccessToken: - return SDKAccessToken( - token="opaque", - client_id="client-1", - scopes=[], - subject=subject, - claims={"iss": "https://issuer.example"}, - ) - - -@contextmanager -def as_principal(token: SDKAccessToken | None) -> Iterator[None]: - if token is None: - yield - return - reset = auth_context_var.set(AuthenticatedUser(token)) - try: - yield - finally: - auth_context_var.reset(reset) - - -def build_injected_server() -> FastMCP: - """Server whose cart tools inject a per-user `session: UserSession`.""" - server = FastMCP("shop") - - @server.tool - async def add_to_cart(item: str, session: UserSession) -> int: - cart = await session.get("cart", default=[]) - cart.append(item) - await session.set("cart", cart) - return len(cart) - - @server.tool - async def view_cart(session: UserSession) -> list[str]: - return await session.get("cart", default=[]) - - return server - - -def build_id_server() -> FastMCP: - """Server whose cart tools take an explicit `session_id: SessionId`.""" - server = FastMCP("shop") - server.add_provider(SessionProvider()) - - @server.tool - async def add_to_cart(item: str, session_id: SessionId) -> int: - session = await get_session(session_id) - cart = await session.get("cart", default=[]) - cart.append(item) - await session.set("cart", cart) - return len(cart) - - @server.tool - async def view_cart(session_id: SessionId) -> list[str]: - session = await get_session(session_id) - return await session.get("cart", default=[]) - - return server - - -# --------------------------------------------------------------------------- -# Injected `session: UserSession` -# --------------------------------------------------------------------------- - - -class TestInjectedSession: - async def test_not_in_input_schema(self): - server = build_injected_server() - async with Client(server) as client: - tools = {t.name: t for t in await client.list_tools()} - schema = tools["add_to_cart"].input_schema - assert "session" not in schema["properties"] - assert "item" in schema["properties"] - - async def test_errors_without_auth(self): - server = build_injected_server() - async with Client(server) as client: - with pytest.raises(ToolError): - await client.call_tool("view_cart", {}) - - async def test_state_survives_across_calls_per_user(self): - server = build_injected_server() - add_tool = await server.get_tool("add_to_cart") - view_tool = await server.get_tool("view_cart") - assert add_tool is not None - assert view_tool is not None - - async with Context(fastmcp=server): - with as_principal(make_token(subject="user-a")): - await add_tool.run({"item": "apple"}) - second = await add_tool.run({"item": "banana"}) - view = await view_tool.run({}) - - assert result_value(second) == 2 - assert result_value(view) == ["apple", "banana"] - - async def test_two_principals_get_isolated_buckets(self): - server = build_injected_server() - add_tool = await server.get_tool("add_to_cart") - view_tool = await server.get_tool("view_cart") - assert add_tool is not None - assert view_tool is not None - - async with Context(fastmcp=server): - with as_principal(make_token(subject="user-a")): - await add_tool.run({"item": "apple"}) - with as_principal(make_token(subject="user-b")): - view_b = await view_tool.run({}) - - assert result_value(view_b) == [] - - async def test_injected_value_is_a_user_session_instance(self): - """The handler receives a `UserSession`, not a bare `Session` — so - `isinstance(session, UserSession)` holds for code that keys off it.""" - server = FastMCP("shop") - - @server.tool - async def whoami(session: UserSession) -> bool: - return isinstance(session, UserSession) - - tool = await server.get_tool("whoami") - assert tool is not None - async with Context(fastmcp=server): - with as_principal(make_token()): - result = await tool.run({}) - assert result_value(result) is True - - async def test_optional_session_is_none_without_auth(self): - """`session: UserSession | None = None` injects `None` on an - unauthenticated request instead of raising.""" - server = FastMCP("shop") - - @server.tool - async def maybe(session: UserSession | None = None) -> bool: - return session is None - - tool = await server.get_tool("maybe") - assert tool is not None - async with Context(fastmcp=server): - result = await tool.run({}) - assert result_value(result) is True - - async def test_optional_session_is_present_with_auth(self): - """The same optional parameter injects a real `UserSession` when the - request is authenticated.""" - server = FastMCP("shop") - - @server.tool - async def maybe(session: UserSession | None = None) -> bool: - return isinstance(session, UserSession) - - tool = await server.get_tool("maybe") - assert tool is not None - async with Context(fastmcp=server): - with as_principal(make_token()): - result = await tool.run({}) - assert result_value(result) is True - - async def test_optional_session_not_in_input_schema(self): - """An optional injected session is still excluded from the schema.""" - server = FastMCP("shop") - - @server.tool - async def maybe(session: UserSession | None = None) -> bool: - return session is None - - async with Client(server) as client: - tools = {t.name: t for t in await client.list_tools()} - assert "session" not in tools["maybe"].input_schema.get("properties", {}) - - async def test_user_session_needs_no_provider(self): - """A server using only `UserSession` requires no `SessionProvider` and - lists no lifecycle tools.""" - server = build_injected_server() - async with Client(server) as client: - names = {t.name for t in await client.list_tools()} - assert "create_session" not in names - assert "end_session" not in names - - async def test_storage_key_does_not_embed_the_raw_principal(self): - """The injected session is stored under the reserved per-user id, not - under the raw principal JSON — proven by reconstructing a `Session` - against the reserved id and reading back what injection wrote.""" - from fastmcp.server.sessions import ( - _USER_SESSION_ID, - Session, - current_principal, - session_storage_key, - ) - - server = build_injected_server() - add_tool = await server.get_tool("add_to_cart") - assert add_tool is not None - - token = make_token(subject="user-a") - async with Context(fastmcp=server): - with as_principal(token): - await add_tool.run({"item": "apple"}) - principal = current_principal() - - assert principal is not None - assert token.subject is not None - # The raw principal never appears in the storage key itself. - key = session_storage_key(principal, _USER_SESSION_ID) - assert principal not in key - assert token.subject not in key - assert token.client_id not in key - - # And the reserved-id reconstruction reads back what injection wrote, - # proving injection actually used `_USER_SESSION_ID` as the session id. - reconstructed = Session( - store=server._state_store, - principal=principal, - session_id=_USER_SESSION_ID, - ) - assert await reconstructed.get("cart") == ["apple"] - - -# --------------------------------------------------------------------------- -# Explicit `session_id: SessionId` — create then validate -# --------------------------------------------------------------------------- - - -class TestSessionIdArgument: - async def test_session_id_is_a_required_string_with_contract_description(self): - server = build_id_server() - async with Client(server) as client: - tools = {t.name: t for t in await client.list_tools()} - schema = tools["view_cart"].input_schema - prop = schema["properties"]["session_id"] - assert prop["type"] == "string" - assert "session_id" in schema["required"] - assert prop["description"] == SESSION_ID_DESCRIPTION - - async def test_created_id_round_trips_state_across_calls(self): - server = build_id_server() - async with Client(server) as client: - session_id = (await client.call_tool("create_session", {})).data - - await client.call_tool( - "add_to_cart", {"item": "apple", "session_id": session_id} - ) - second = await client.call_tool( - "add_to_cart", {"item": "banana", "session_id": session_id} - ) - assert second.data == 2 - - view = await client.call_tool("view_cart", {"session_id": session_id}) - assert view.data == ["apple", "banana"] - - async def test_resolved_session_exposes_its_id(self): - """A session resolved from a `session_id` argument carries that id.""" - server = FastMCP("shop") - server.add_provider(SessionProvider()) - - @server.tool - async def which_session(session_id: SessionId) -> str | None: - return (await get_session(session_id)).id - - async with Client(server) as client: - session_id = (await client.call_tool("create_session", {})).data - result = ( - await client.call_tool("which_session", {"session_id": session_id}) - ).data - assert result == session_id - - async def test_uncreated_id_is_rejected(self): - """An id that was never handed out by `create_session` does not resolve.""" - server = build_id_server() - async with Client(server) as client: - with pytest.raises(ToolError): - await client.call_tool("view_cart", {"session_id": "never-created"}) - - async def test_distinct_created_ids_are_isolated(self): - server = build_id_server() - async with Client(server) as client: - id_a = (await client.call_tool("create_session", {})).data - id_b = (await client.call_tool("create_session", {})).data - assert id_a != id_b - - await client.call_tool("add_to_cart", {"item": "apple", "session_id": id_a}) - view_b = await client.call_tool("view_cart", {"session_id": id_b}) - assert view_b.data == [] - - async def test_end_session_invalidates_the_session(self): - """After `end_session` the id no longer resolves at all.""" - server = build_id_server() - async with Client(server) as client: - session_id = (await client.call_tool("create_session", {})).data - await client.call_tool( - "add_to_cart", {"item": "apple", "session_id": session_id} - ) - - await client.call_tool("end_session", {"session_id": session_id}) - - with pytest.raises(ToolError): - await client.call_tool("view_cart", {"session_id": session_id}) - - async def test_clear_keeps_the_session_valid(self): - """`session.clear()` empties state but the session still resolves.""" - server = FastMCP("shop") - server.add_provider(SessionProvider()) - - @server.tool - async def add_to_cart(item: str, session_id: SessionId) -> int: - session = await get_session(session_id) - cart = await session.get("cart", default=[]) - cart.append(item) - await session.set("cart", cart) - return len(cart) - - @server.tool - async def clear_cart(session_id: SessionId) -> str: - session = await get_session(session_id) - await session.clear() - return "cleared" - - @server.tool - async def view_cart(session_id: SessionId) -> list[str]: - session = await get_session(session_id) - return await session.get("cart", default=[]) - - async with Client(server) as client: - session_id = (await client.call_tool("create_session", {})).data - await client.call_tool( - "add_to_cart", {"item": "apple", "session_id": session_id} - ) - await client.call_tool("clear_cart", {"session_id": session_id}) - - # Still resolves (no error), and state is empty. - view = await client.call_tool("view_cart", {"session_id": session_id}) - assert view.data == [] - - async def test_created_under_one_principal_rejected_under_another(self): - """An id created by principal A is rejected when used by principal B.""" - server = build_id_server() - create_tool = await server.get_tool("create_session") - view_tool = await server.get_tool("view_cart") - assert create_tool is not None - assert view_tool is not None - - async with Context(fastmcp=server): - with as_principal(make_token(subject="user-a")): - created = await create_tool.run({}) - session_id = result_value(created) - with as_principal(make_token(subject="user-b")): - with pytest.raises(InvalidSession): - await view_tool.run({"session_id": session_id}) - with as_principal(make_token(subject="user-a")): - # A's own session still resolves. - view_a = await view_tool.run({"session_id": session_id}) - - assert result_value(view_a) == [] - - async def test_two_principals_same_id_are_isolated(self): - server = build_id_server() - create_tool = await server.get_tool("create_session") - add_tool = await server.get_tool("add_to_cart") - view_tool = await server.get_tool("view_cart") - assert create_tool is not None - assert add_tool is not None - assert view_tool is not None - - async with Context(fastmcp=server): - with as_principal(make_token(subject="user-a")): - id_a = result_value(await create_tool.run({})) - await add_tool.run({"item": "apple", "session_id": id_a}) - with as_principal(make_token(subject="user-b")): - id_b = result_value(await create_tool.run({})) - # B's own session under its own id is empty. - view_b = await view_tool.run({"session_id": id_b}) - assert result_value(view_b) == [] - with as_principal(make_token(subject="user-a")): - view_a = await view_tool.run({"session_id": id_a}) - - assert result_value(view_a) == ["apple"] - - -# --------------------------------------------------------------------------- -# SessionProvider -# --------------------------------------------------------------------------- - - -class TestSessionProvider: - async def test_lifecycle_tools_registered_via_add_provider(self): - server = FastMCP("s") - server.add_provider(SessionProvider()) - async with Client(server) as client: - names = {t.name for t in await client.list_tools()} - assert {"create_session", "end_session"} <= names - - async def test_create_session_returns_a_uuid_string(self): - server = FastMCP("s") - server.add_provider(SessionProvider()) - async with Client(server) as client: - session_id = (await client.call_tool("create_session", {})).data - assert isinstance(session_id, str) - # Parses as a uuid4 and is unguessable (not a fixed/empty value). - assert str(UUID(session_id)) == session_id - - async def test_create_session_ids_are_distinct(self): - server = FastMCP("s") - server.add_provider(SessionProvider()) - async with Client(server) as client: - first = (await client.call_tool("create_session", {})).data - second = (await client.call_tool("create_session", {})).data - assert first != second - - async def test_end_session_declares_session_id_contract(self): - server = FastMCP("s") - server.add_provider(SessionProvider()) - async with Client(server) as client: - tools = {t.name: t for t in await client.list_tools()} - prop = tools["end_session"].input_schema["properties"]["session_id"] - assert prop["type"] == "string" - assert SESSION_ID_DESCRIPTION in prop["description"] - - -class TestNoProviderIsNonFatal: - """With the enforcement checks removed, a `session_id` tool without a - `SessionProvider` is not a setup error — it simply cannot resolve a session, - because no id can be created. The failure surfaces at use, not at listing.""" - - async def test_session_id_tool_lists_without_a_provider(self): - server = FastMCP("shop") - - @server.tool - async def add_to_cart(item: str, session_id: SessionId) -> int: - return len(item) - - async with Client(server) as client: - names = {t.name for t in await client.list_tools()} - assert names == {"add_to_cart"} - - async def test_any_id_is_rejected_without_a_way_to_create_one(self): - server = FastMCP("shop") - - @server.tool - async def add_to_cart(item: str, session_id: SessionId) -> str: - session = await get_session(session_id) - await session.set("item", item) - return "ok" - - # No provider, so no id was ever minted: resolution rejects any id. - async with Client(server) as client: - with pytest.raises(ToolError): - await client.call_tool( - "add_to_cart", {"item": "x", "session_id": "made-up"} - ) - - -class TestSessionIdDescriptionAppending: - async def test_author_description_is_preserved_and_appended(self): - server = FastMCP("s") - server.add_provider(SessionProvider()) - - @server.tool - async def resume(session_id: SessionId) -> str: - """Resume work. - - Args: - session_id: The handle for this workflow. - """ - return session_id - - async with Client(server) as client: - tools = {t.name: t for t in await client.list_tools()} - desc = tools["resume"].input_schema["properties"]["session_id"]["description"] - assert "The handle for this workflow." in desc - assert SESSION_ID_DESCRIPTION in desc - # Author text comes first, contract appended after. - assert re.search(r"handle for this workflow\.\s+Session identifier\.", desc) - - async def test_contract_is_not_duplicated_when_author_repeats_it(self): - """An author who already includes the contract text doesn't get it twice.""" - from typing import Annotated - - from pydantic import Field - - server = FastMCP("s") - server.add_provider(SessionProvider()) - - @server.tool - async def resume( - session_id: Annotated[SessionId, Field(description=SESSION_ID_DESCRIPTION)], - ) -> str: - return session_id - - async with Client(server) as client: - tools = {t.name: t for t in await client.list_tools()} - desc = tools["resume"].input_schema["properties"]["session_id"]["description"] - assert desc.count(SESSION_ID_DESCRIPTION) == 1 - - async def test_description_survives_namespaced_mount(self): - """The contract names no specific tool, so it stays correct when a mount - renames the lifecycle tool under a namespace — the description must not - point agents at an unqualified `create_session` that does not exist - under that mount.""" - child = FastMCP("child") - child.add_provider(SessionProvider()) - - @child.tool - async def workflow(session_id: SessionId) -> str: - return session_id - - parent = FastMCP("parent") - parent.mount(child, namespace="child") - - async with Client(parent) as client: - tools = {t.name: t for t in await client.list_tools()} - - # The lifecycle tool is renamed under the namespace... - assert "child_create_session" in tools - assert "create_session" not in tools - # ...yet the session_id contract still resolves correctly, because it - # describes the capability rather than naming a tool. - desc = tools["child_workflow"].input_schema["properties"]["session_id"][ - "description" - ] - assert desc == SESSION_ID_DESCRIPTION - assert "create_session" not in desc diff --git a/tests/server/test_session_visibility.py b/tests/server/test_session_visibility.py index 2e0d5ddc7..d98a0d72f 100644 --- a/tests/server/test_session_visibility.py +++ b/tests/server/test_session_visibility.py @@ -48,16 +48,7 @@ class RecordingMessageHandler(MessageHandler): class TestSessionVisibility: - """Test session-specific visibility control via Context. - - Session-scoped visibility rules are stored under `ctx.session_id`. The - modern protocol version is stateless: each request gets a fresh - connection identity, so a rule set in one request is gone by the next. - Tests that only check state within a single tool call are era-neutral - and stay unpinned; tests that activate a rule in one request and observe - its effect in a later request are pinned to the handshake era, where the - rule's persistence is the very thing under test. - """ + """Test session-specific visibility control via Context.""" async def test_enable_components_stores_rule_dict(self): """Test that enable_components stores a rule dict in session state.""" @@ -125,7 +116,7 @@ class TestSessionVisibility: # Globally disable finance tools mcp.disable(tags={"finance"}) - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Before activation, finance tool should not be visible tools_before = await client.list_tools() assert not any(t.name == "finance_tool" for t in tools_before) @@ -160,7 +151,7 @@ class TestSessionVisibility: # Globally disable finance tools mcp.disable(tags={"finance"}) - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Activate finance await client.call_tool("activate_finance", {}) @@ -191,13 +182,13 @@ class TestSessionVisibility: mcp.disable(tags={"finance"}) # Session A activates finance - async with Client(mcp, mode="legacy") as client_a: + async with Client(mcp) as client_a: await client_a.call_tool("activate_finance", {}) tools_a = await client_a.list_tools() assert any(t.name == "finance_tool" for t in tools_a) # Session B should not see finance tool (different session) - async with Client(mcp, mode="legacy") as client_b: + async with Client(mcp) as client_b: tools_b = await client_b.list_tools() assert not any(t.name == "finance_tool" for t in tools_b) @@ -229,7 +220,7 @@ class TestSessionVisibility: # Globally disable all versioned tools mcp.disable(names={"old_tool", "new_tool"}) - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Enable v2 tools await client.call_tool("enable_v2_only", {}) @@ -263,7 +254,7 @@ class TestSessionVisibility: # Globally disable finance tools mcp.disable(tags={"finance"}) - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Activate finance await client.call_tool("activate_finance", {}) tools_after_activate = await client.list_tools() @@ -301,7 +292,7 @@ class TestSessionVisibility: # Globally disable finance and admin tools mcp.disable(tags={"finance", "admin"}) - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Activate both await client.call_tool("activate_multiple", {}) @@ -327,7 +318,7 @@ class TestSessionVisibility: await ctx.disable_components(tags={"test"}) return "toggled" - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Toggle (enable then disable) await client.call_tool("toggle_test", {}) @@ -353,7 +344,7 @@ class TestSessionVisibility: # Globally disable finance resources mcp.disable(tags={"finance"}) - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Before activation, finance resource should not be visible resources_before = await client.list_resources() assert not any(str(r.uri) == "resource://finance" for r in resources_before) @@ -383,7 +374,7 @@ class TestSessionVisibility: # Globally disable finance prompts mcp.disable(tags={"finance"}) - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Before activation, finance prompt should not be visible prompts_before = await client.list_prompts() assert not any(p.name == "finance_prompt" for p in prompts_before) @@ -546,7 +537,7 @@ class TestConcurrentSessionIsolation: async def session_a(): nonlocal session_a_sees_finance - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Activate finance for this session await client.call_tool("activate_finance", {}) @@ -565,7 +556,7 @@ class TestConcurrentSessionIsolation: # Wait for session A to activate await ready_event.wait() - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Session B should NOT see finance tool tools = await client.list_tools() session_b_sees_finance = any(t.name == "finance_tool" for t in tools) @@ -599,13 +590,13 @@ class TestConcurrentSessionIsolation: results: dict[str, bool] = {} async def activated_session(session_id: str): - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: await client.call_tool("activate_premium", {}) tools = await client.list_tools() results[session_id] = any(t.name == "premium_tool" for t in tools) async def non_activated_session(session_id: str): - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: tools = await client.list_tools() results[session_id] = any(t.name == "premium_tool" for t in tools) @@ -653,7 +644,7 @@ class TestSessionVisibilityResetBug: await ctx.reset_visibility() return "exited" - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Tool visible initially tools = await client.list_tools() assert any(t.name == "my_tool" for t in tools) @@ -690,7 +681,7 @@ class TestSessionVisibilityResetBug: await ctx.reset_visibility() return "exited" - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: for i in range(3): # create_project should be visible tools = await client.list_tools() @@ -728,7 +719,7 @@ class TestSessionVisibilityResetBug: check_done = anyio.Event() async def session_a(): - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: await client.call_tool("disable_system", {}) ready.set() await check_done.wait() @@ -736,7 +727,7 @@ class TestSessionVisibilityResetBug: async def session_b(): nonlocal session_b_sees_tool await ready.wait() - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: tools = await client.list_tools() session_b_sees_tool = any(t.name == "shared_tool" for t in tools) check_done.set() @@ -765,13 +756,13 @@ class TestSessionVisibilityResetBug: return "disabled" # Session A disables the tool (no reset) - async with Client(mcp, mode="legacy") as client_a: + async with Client(mcp) as client_a: await client_a.call_tool("disable_system", {}) tools = await client_a.list_tools() assert not any(t.name == "shared_tool" for t in tools) # Session B should see it fresh - async with Client(mcp, mode="legacy") as client_b: + async with Client(mcp) as client_b: tools = await client_b.list_tools() assert any(t.name == "shared_tool" for t in tools), ( "New session should see shared_tool regardless of previous session" diff --git a/tests/server/test_sessions.py b/tests/server/test_sessions.py deleted file mode 100644 index 8f1403e2a..000000000 --- a/tests/server/test_sessions.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Unit tests for the stateless session-state primitives. - -Covers the principal helpers, the `(principal, session_id)` key scheme, and the -`Session` object's read-modify-write behavior against a real server store. -""" - -import functools -import json -from collections.abc import Iterator -from contextlib import contextmanager - -from mcp.server.auth.middleware.auth_context import auth_context_var -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken as SDKAccessToken -from mcp.server.auth.provider import principal_components - -from fastmcp.server.server import FastMCP -from fastmcp.server.sessions import ( - Session, - SessionId, - current_principal, - session_id_parameter_names, - session_storage_key, -) - - -def make_token( - *, subject: str = "user-a", client_id: str = "client-1" -) -> SDKAccessToken: - return SDKAccessToken( - token="opaque", - client_id=client_id, - scopes=[], - subject=subject, - claims={"iss": "https://issuer.example"}, - ) - - -def principal_string(token: SDKAccessToken) -> str: - return json.dumps(principal_components(token), separators=(",", ":")) - - -@contextmanager -def as_principal(token: SDKAccessToken | None) -> Iterator[None]: - if token is None: - yield - return - reset = auth_context_var.set(AuthenticatedUser(token)) - try: - yield - finally: - auth_context_var.reset(reset) - - -def make_session(server: FastMCP, principal: str | None, session_id: str) -> Session: - return Session( - store=server._state_store, principal=principal, session_id=session_id - ) - - -class TestPrincipalHelpers: - def test_current_principal_none_without_auth(self): - assert current_principal() is None - - def test_current_principal_encodes_triple(self): - token = make_token() - with as_principal(token): - assert current_principal() == principal_string(token) - - -class TestStorageKey: - def test_principal_is_the_isolation_wall(self): - principal_a = principal_string(make_token(subject="user-a")) - principal_b = principal_string(make_token(subject="user-b")) - # Same session id, different principals -> different keys. - assert session_storage_key(principal_a, "s1") != session_storage_key( - principal_b, "s1" - ) - - def test_id_organizes_within_a_principal(self): - principal = principal_string(make_token()) - assert session_storage_key(principal, "s1") != session_storage_key( - principal, "s2" - ) - - def test_unauthenticated_collapses_to_shared_namespace(self): - assert session_storage_key(None, "s1").startswith("session:anon:") - - def test_principal_not_embedded_verbatim(self): - principal = principal_string(make_token()) - # The principal is hashed into a fixed segment, never embedded raw. - assert principal not in session_storage_key(principal, "s1") - - -class TestSessionRoundTrip: - async def test_set_get_delete(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - assert await session.get("missing") is None - assert await session.get("missing", default=[]) == [] - - await session.set("cart", ["apple"]) - assert await session.get("cart") == ["apple"] - - await session.delete("cart") - assert await session.get("cart") is None - - async def test_multiple_keys_share_one_dict(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - await session.set("a", 1) - await session.set("b", 2) - assert await session.get("a") == 1 - assert await session.get("b") == 2 - - async def test_clear_removes_everything(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - await session.set("a", 1) - await session.set("b", 2) - await session.clear() - assert await session.get("a") is None - assert await session.get("b") is None - - async def test_delete_missing_key_is_a_noop(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - await session.delete("nope") # does not raise - assert await session.get("nope") is None - - -class TestSessionIdProperty: - def test_id_is_none_without_a_public_id(self): - # An injected `UserSession` is built this way — no distinct public id. - server = FastMCP("test") - assert make_session(server, None, "s1").id is None - - def test_id_returns_the_public_id(self): - server = FastMCP("test") - session = Session( - store=server._state_store, - principal=None, - session_id="s1", - public_id="s1", - ) - assert session.id == "s1" - - -class TestSessionIsolation: - async def test_distinct_ids_are_isolated(self): - server = FastMCP("test") - principal = principal_string(make_token()) - await make_session(server, principal, "s1").set("cart", ["apple"]) - assert await make_session(server, principal, "s2").get("cart") is None - - async def test_same_id_different_principals_are_isolated(self): - server = FastMCP("test") - principal_a = principal_string(make_token(subject="user-a")) - principal_b = principal_string(make_token(subject="user-b")) - await make_session(server, principal_a, "shared-id").set("cart", ["a-item"]) - # B passes the *same* session id but reaches its own empty bucket. - assert await make_session(server, principal_b, "shared-id").get("cart") is None - # A still sees its own data. - assert await make_session(server, principal_a, "shared-id").get("cart") == [ - "a-item" - ] - - -class TestSharedStore: - async def test_sessions_share_the_one_server_store(self): - """A second Session for the same key sees the first's writes.""" - server = FastMCP("test") - await make_session(server, None, "s1").set("x", 42) - # A freshly constructed handle for the same (principal, id) reads it back. - assert await make_session(server, None, "s1").get("x") == 42 - - -class TestFalsyValues: - async def test_stored_falsy_value_is_not_treated_as_missing(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - await session.set("count", 0) - await session.set("flag", False) - assert await session.get("count", default=99) == 0 - assert await session.get("flag", default=True) is False - - -class TestLifecycleMarker: - async def test_uncreated_session_does_not_exist(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - assert await session._exists() is False - - async def test_created_session_exists(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - await session._create() - assert await session._exists() is True - - async def test_writing_state_does_not_clobber_the_marker(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - await session._create() - # A user key literally named like the marker cannot collide with it, - # because user state lives in a namespaced sub-dict. - await session.set("_created", "user-value") - await session.set("cart", ["apple"]) - await session.delete("cart") - assert await session._exists() is True - assert await session.get("_created") == "user-value" - - async def test_clear_keeps_the_session_but_empties_state(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - await session._create() - await session.set("cart", ["apple"]) - await session.clear() - assert await session._exists() is True - assert await session.get("cart") is None - - async def test_end_removes_the_session_entirely(self): - server = FastMCP("test") - session = make_session(server, None, "s1") - await session._create() - await session.set("cart", ["apple"]) - await session.end() - assert await session._exists() is False - assert await session.get("cart") is None - - -class TestSessionIdParameterNames: - def test_detects_plain_parameter(self): - def tool(item: str, session_id: SessionId) -> None: ... - - assert session_id_parameter_names(tool) == ("session_id",) - - def test_none_when_absent(self): - def tool(item: str) -> None: ... - - assert session_id_parameter_names(tool) == () - - def test_partial_positional_binding_is_dropped(self): - # A positionally bound leading argument is no longer part of the tool's - # argument surface; the `session_id` that remains is still detected. - def tool(item: str, session_id: SessionId) -> None: ... - - bound = functools.partial(tool, "apple") - assert session_id_parameter_names(bound) == ("session_id",) - - def test_partial_binding_the_session_id_positionally_drops_it(self): - def tool(session_id: SessionId, item: str) -> None: ... - - bound = functools.partial(tool, "s1") - assert session_id_parameter_names(bound) == () - - def test_partial_keyword_binding_stays_detected(self): - # A keyword-bound partial argument remains overridable by the caller, so - # it is still in the tool's input schema — detection tracks the schema - # and keeps populating its description. - def tool(item: str, session_id: SessionId) -> None: ... - - bound = functools.partial(tool, session_id="s1") - assert session_id_parameter_names(bound) == ("session_id",) diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index 44442fc76..096fd451d 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -5,7 +5,6 @@ from mcp_types import ToolAnnotations, ToolExecution from fastmcp import Client, FastMCP from fastmcp.tools.base import Tool -from fastmcp_tasks import TasksExtension from tests.conftest import make_server_request_context @@ -224,22 +223,19 @@ async def test_tool_functionality_with_annotations(): async def test_task_execution_auto_populated_for_task_enabled_tool(): """Test that execution.task_support is automatically set when tool has task=True.""" mcp = FastMCP("Test Server") - mcp.add_extension(TasksExtension()) @mcp.tool(task=True) async def background_tool(data: str) -> str: """A tool that runs in background.""" return f"Processed: {data}" - # The rendered tool descriptor auto-populates `execution.task_support` from - # the tool's task config. (The modern wire drops the SEP-1686 `execution` - # field, so this is asserted on the server-side render.) - tool = await mcp.get_tool("background_tool") - assert tool is not None - mcp_tool = tool.to_mcp_tool() - assert isinstance(mcp_tool, MCPTool) - assert isinstance(mcp_tool.execution, ToolExecution) - assert mcp_tool.execution.task_support == "optional" + async with Client(mcp) as client: + tools_result = await client.list_tools() + assert len(tools_result) == 1 + assert tools_result[0].name == "background_tool" + assert isinstance(tools_result[0], MCPTool) + assert isinstance(tools_result[0].execution, ToolExecution) + assert tools_result[0].execution.task_support == "optional" async def test_task_execution_omitted_for_task_disabled_tool(): diff --git a/tests/server/transforms/test_model_visibility_boundary.py b/tests/server/transforms/test_model_visibility_boundary.py deleted file mode 100644 index f1d98b67d..000000000 --- a/tests/server/transforms/test_model_visibility_boundary.py +++ /dev/null @@ -1,168 +0,0 @@ -"""App-only tools must not reach the model through server-driven surfaces. - -`tools/list` carries app-only tools on purpose — intermediaries need them to -forward, and the MCP Apps spec puts visibility filtering on the host. That -division holds only where a host sits between the server and the model. - -A search result, a code-mode catalog, and a call-tool proxy are all driven by -the server itself: the first two reach the model as ordinary tool output, and -the third invokes on a name the model supplies. No host mediates any of them, -so the visibility declaration has to be applied server-side. -""" - -from __future__ import annotations - -import json - -import pytest - -from fastmcp import Client, FastMCP, FastMCPApp -from fastmcp.exceptions import ToolError -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.server.providers.addressing import hashed_backend_name -from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform -from fastmcp.tools.base import Tool - - -def build_server_without_transform() -> FastMCP: - return _build(None) - - -def build_server(transform) -> FastMCP: - return _build(transform) - - -def _build(transform) -> FastMCP: - app = FastMCPApp("contacts") - - @app.tool() - def save_contact(name: str) -> str: - """UI-only backend that writes a contact.""" - return f"saved {name}" - - @app.tool(model=True) - def search_contacts(query: str) -> str: - """Model-visible backend.""" - return f"found {query}" - - @app.ui() - def contacts_ui() -> str: - return "ui" - - server = FastMCP("Platform") - server.add_provider(app) - if transform is not None: - server.add_transform(transform) - return server - - -CATALOG_TRANSFORMS = [ - pytest.param(RegexSearchTransform, id="regex-search"), - pytest.param(BM25SearchTransform, id="bm25-search"), - pytest.param(CodeMode, id="code-mode"), -] - - -@pytest.mark.parametrize("transform_cls", CATALOG_TRANSFORMS) -async def test_app_only_tools_stay_out_of_model_catalogs(transform_cls): - """Discovery surfaces hand tool definitions straight to the model.""" - server = build_server(transform_cls()) - - async with Client(server) as client: - blob = "" - for tool in await client.list_tools(): - if "search" not in tool.name: - continue - # Each transform names its search argument differently; the - # schema is the authority. - (argument,) = (tool.input_schema or {}).get("required", ["query"]) - result = await client.call_tool(tool.name, {argument: "search_contacts"}) - blob += json.dumps(result.structured_content or "") - blob += "".join( - block.text for block in result.content if hasattr(block, "text") - ) - - assert blob, "no search surface produced output" - assert "save_contact" not in blob - assert "search_contacts" in blob - - -async def test_app_only_tools_are_listed_for_forwarding(): - """The wire listing keeps them: a proxy cannot forward what it cannot see. - - Only the model-facing catalog is filtered, so a server without a catalog - transform still advertises the tool and its declaration for a host to - act on. - """ - plain = build_server_without_transform() - - async with Client(plain) as client: - listed = {tool.name: tool for tool in await client.list_tools()} - - assert "save_contact" in listed - assert listed["save_contact"].meta is not None - assert listed["save_contact"].meta["ui"]["visibility"] == ["app"] - - -async def test_call_tool_proxy_refuses_undiscoverable_tools(): - """The proxy takes a model-supplied name, so it is a second door in.""" - server = build_server(RegexSearchTransform()) - - async with Client(server) as client: - with pytest.raises(ToolError, match="save_contact"): - await client.call_tool( - "call_tool", - {"name": "save_contact", "arguments": {"name": "eve"}}, - ) - - allowed = await client.call_tool( - "call_tool", - {"name": "search_contacts", "arguments": {"query": "ada"}}, - ) - assert allowed.content[0].text == "found ada" # type: ignore[union-attr] - - -@pytest.mark.parametrize("transform_cls", CATALOG_TRANSFORMS) -async def test_the_apps_own_ui_still_reaches_its_backend(transform_cls): - """The point of the boundary is the audience, not the tool: a UI calling - by identity is not the model, and must still work. - """ - server = build_server(transform_cls()) - - result = await server.call_tool( - hashed_backend_name("contacts", "save_contact"), {"name": "ada"} - ) - assert result.content[0].text == "saved ada" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - -async def test_visibility_is_checked_on_the_version_a_name_reaches(): - """A bare name selects the highest version, so that is the one whose - declaration governs. Checking before deduplication would advertise a - model-visible older version whose name runs an app-only newer one. - """ - - def versioned(version: str, visibility: list[str], marker: str) -> Tool: - def same() -> str: - return f"ran {marker}" - - return Tool.from_function( - same, name="same", version=version, meta={"ui": {"visibility": visibility}} - ) - - app = FastMCPApp("contacts") - app.add_tool(versioned("1.0.0", ["app", "model"], "v1")) - app.add_tool(versioned("2.0.0", ["app"], "v2")) - - server = FastMCP("Platform") - server.add_provider(app) - server.add_transform(RegexSearchTransform()) - - async with Client(server) as client: - found = await client.call_tool("search_tools", {"pattern": "same"}) - blob = json.dumps(found.structured_content or "") + "".join( - block.text for block in found.content if hasattr(block, "text") - ) - assert "same" not in blob - - with pytest.raises(ToolError, match="same"): - await client.call_tool("call_tool", {"name": "same", "arguments": {}}) diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py index 810a05bdd..981d044f2 100644 --- a/tests/server/transforms/test_search.py +++ b/tests/server/transforms/test_search.py @@ -137,28 +137,6 @@ class TestBaseTransformBehavior: assert await mcp.get_tool("find_tools") is not None assert await mcp.get_tool("run_tool") is not None - @pytest.mark.parametrize( - "transform_cls", [RegexSearchTransform, BM25SearchTransform] - ) - async def test_synthetic_tools_have_titles(self, transform_cls): - """Synthetic search/call tools must carry a title. - - Some MCP clients (e.g. ChatGPT) drop tools with no `title` field, - which breaks tool-search discovery entirely. See #4414. - """ - mcp = _make_server_with_tools() - mcp.add_transform( - transform_cls( - search_tool_name="find_tools", call_tool_name="call_read_tool" - ) - ) - tools = await mcp.list_tools() - titles = {t.name: t.to_mcp_tool().title for t in tools} - assert titles == { - "find_tools": "Find Tools", - "call_read_tool": "Call Read Tool", - } - async def test_search_respects_visibility_filtering(self): """Tools disabled via Visibility transform should not appear in search.""" mcp = _make_server_with_tools() @@ -210,10 +188,7 @@ class TestBaseTransformBehavior: await ctx.disable_components(names={"delete_record"}) return "disabled" - # Session visibility rules only persist across requests on the - # handshake era (see `test_session_visibility.py`); the modern - # protocol version has no session for them to persist in. - async with Client(mcp, mode="legacy") as client: + async with Client(mcp) as client: # Before disabling, search should find delete_record result = await client.call_tool("search_tools", {"pattern": "delete"}) found = _parse_tool_result(result) diff --git a/tests/server/transforms/test_visibility.py b/tests/server/transforms/test_visibility.py index 73379baf5..d3434cc4a 100644 --- a/tests/server/transforms/test_visibility.py +++ b/tests/server/transforms/test_visibility.py @@ -273,50 +273,3 @@ class TestTransformChain: enabled = [t for t in result if is_enabled(t)] assert [t.name for t in enabled] == ["public"] - - -class TestMalformedKeyWarning: - """Keys missing the '@' delimiter match nothing, so warn at construction.""" - - @pytest.mark.parametrize( - "key", - [ - "tool:my_tool", - "resource:data://config", - "prompt:analyze", - ], - ) - def test_warns_on_key_without_delimiter(self, key: str): - """A key with no '@' can never match a real component key.""" - with pytest.warns(UserWarning, match="missing the '@' version delimiter"): - Visibility(False, keys={key}) - - @pytest.mark.parametrize( - "key", - [ - "tool:my_tool@", - "tool:my_tool@v1", - "resource:data://config@", - "resource:data://user@example.com/profile@", - ], - ) - def test_no_warning_on_well_formed_key(self, key: str, recwarn): - """Well-formed keys, including URIs containing '@', pass silently.""" - Visibility(False, keys={key}) - assert [w for w in recwarn if issubclass(w.category, UserWarning)] == [] - - def test_warning_lists_only_malformed_keys(self): - """The message names the offending keys and omits the valid ones.""" - with pytest.warns(UserWarning) as record: - Visibility(False, keys={"tool:good@", "tool:bad"}) - - message = str(record[0].message) - assert "tool:bad" in message - assert "tool:good@" not in message - - def test_other_filters_do_not_warn(self, recwarn): - """Only `keys` is subject to this validation.""" - Visibility(False, names={"my_tool"}) - Visibility(False, tags={"internal"}) - Visibility(False, match_all=True) - assert [w for w in recwarn if issubclass(w.category, UserWarning)] == [] diff --git a/tests/tasks/client/__init__.py b/tests/tasks/client/__init__.py deleted file mode 100644 index 24bcd2c25..000000000 --- a/tests/tasks/client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for MCP SEP-1686 background task client.""" diff --git a/tests/tasks/client/conftest.py b/tests/tasks/client/conftest.py deleted file mode 100644 index 226fada94..000000000 --- a/tests/tasks/client/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Configuration for client task tests.""" - -import secrets -from pathlib import Path - -import pytest - -from fastmcp.utilities.tests import temporary_settings - - -@pytest.fixture(autouse=True) -def isolate_settings_home(_settings_home_root: Path): - """Task-local override of the repo-wide ``isolate_settings_home`` fixture. - - Docket configuration moved out of core ``Settings`` into - ``fastmcp_tasks.settings.DocketSettings``, so the repo-wide fixture's - ``docket__*`` kwargs no longer resolve against core settings. This - override keeps the per-test settings-home isolation while dropping the - removed docket kwargs. - """ - test_home = _settings_home_root / secrets.token_hex(8) - test_home.mkdir() - - with temporary_settings(home=test_home, client_disconnect_timeout=1): - yield diff --git a/tests/tasks/client/test_client_tool_tasks.py b/tests/tasks/client/test_client_tool_tasks.py deleted file mode 100644 index f809b34d0..000000000 --- a/tests/tasks/client/test_client_tool_tasks.py +++ /dev/null @@ -1,152 +0,0 @@ -"""The explicit `ToolTask` handle (the return-quickly surface, SEP-2663). - -`call_tool_task` returns a `ToolTask` as soon as the server accepts the task, so -the caller can do other work and drive it: `status`, `wait`, `result`, `cancel`, -or `await`. This contrasts with `client.call_tool`, which polls to completion -transparently. All tests use a real `Client(mode="auto")` over the in-memory -transport, since tasks are modern-only. -""" - -from __future__ import annotations - -import asyncio - -import pytest -from fastmcp_tasks.models import MISSING_REQUIRED_CLIENT_CAPABILITY -from mcp.shared.exceptions import MCPError - -from fastmcp import Context, FastMCP -from fastmcp.client import Client -from fastmcp.exceptions import ToolError -from fastmcp.utilities.tasks import TaskConfig -from fastmcp_tasks import TasksExtension, ToolTask, call_tool_task - - -@pytest.fixture -def tool_task_server() -> FastMCP: - mcp = FastMCP("tool-task-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def echo(message: str) -> str: - return f"Echo: {message}" - - @mcp.tool(task=True) - async def multiply(a: int, b: int) -> int: - return a * b - - @mcp.tool(task=True) - async def boom() -> str: - raise ValueError("background task failure") - - return mcp - - -async def test_call_tool_task_returns_tool_task(tool_task_server: FastMCP): - async with Client(tool_task_server, mode="auto") as client: - task = await call_tool_task(client, "echo", {"message": "hello"}) - - assert isinstance(task, ToolTask) - assert isinstance(task.task_id, str) - assert task.task_id - - -async def test_tool_task_result_returns_parsed_result(tool_task_server: FastMCP): - async with Client(tool_task_server, mode="auto") as client: - task = await call_tool_task(client, "multiply", {"a": 6, "b": 7}) - result = await task.result() - assert result.data == 42 - - -async def test_tool_task_await_syntax(tool_task_server: FastMCP): - async with Client(tool_task_server, mode="auto") as client: - task = await call_tool_task(client, "multiply", {"a": 7, "b": 6}) - result = await task - assert result.data == 42 - - -async def test_tool_task_status_and_wait(tool_task_server: FastMCP): - async with Client(tool_task_server, mode="auto") as client: - task = await call_tool_task(client, "echo", {"message": "test"}) - - status = await task.status() - assert status.task_id == task.task_id - assert status.status in {"working", "completed"} - - final = await task.wait(timeout=2.0) - assert final.status == "completed" - - -async def test_tool_task_result_is_cached(tool_task_server: FastMCP): - """Repeated result() calls return the same cached object without re-polling.""" - async with Client(tool_task_server, mode="auto") as client: - task = await call_tool_task(client, "multiply", {"a": 2, "b": 5}) - - result1 = await task.result() - result2 = await task.result() - result3 = await task - assert result1 is result2 is result3 - assert result1.data == 10 - - -async def test_background_task_raises_on_error_by_default(tool_task_server: FastMCP): - async with Client(tool_task_server, mode="auto") as client: - task = await call_tool_task(client, "boom", {}) - with pytest.raises(ToolError, match="background task failure"): - await task.result() - - -async def test_background_task_returns_error_when_not_raising( - tool_task_server: FastMCP, -): - async with Client(tool_task_server, mode="auto") as client: - task = await call_tool_task(client, "boom", {}, raise_on_error=False) - result = await task.result() - assert result.is_error - assert "background task failure" in str(result) - - -async def test_multiple_concurrent_tool_tasks(tool_task_server: FastMCP): - async with Client(tool_task_server, mode="auto") as client: - tasks = [ - (await call_tool_task(client, "multiply", {"a": i, "b": 2}), i * 2) - for i in range(5) - ] - for task, expected in tasks: - result = await task.result() - assert result.data == expected - - -async def test_tool_task_cancel(): - """A long-running task can be cancelled through the handle.""" - mcp = FastMCP("cancel-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def forever(ctx: Context) -> str: - await asyncio.Event().wait() - return "never" - - async with Client(mcp, mode="auto") as client: - task = await call_tool_task(client, "forever", {}) - await task.wait(state="working", timeout=2.0) - await task.cancel() - final = await task.wait(timeout=2.0) - assert final.status == "cancelled" - - -async def test_required_mode_without_optin_raises_32021(): - """A legacy client never negotiates the tasks capability, so a required-mode - tool call is rejected with the -32021 missing-capability error.""" - mcp = FastMCP("required-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=TaskConfig(mode="required")) - async def must_task(x: int) -> int: - return x - - async with Client(mcp, mode="legacy") as client: - with pytest.raises(MCPError) as excinfo: - await client.call_tool("must_task", {"x": 1}) - - assert excinfo.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY diff --git a/tests/tasks/client/test_poll_interval.py b/tests/tasks/client/test_poll_interval.py deleted file mode 100644 index 5d0afab3f..000000000 --- a/tests/tasks/client/test_poll_interval.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Fallback poll cadence for client-side task waiting (SEP-2663). - -The modern protocol has no task status notifications, so the client polls. The -backoff ramps from a fast floor, doubling up to a ceiling: the server-advertised -``pollIntervalMs`` when present (a statement about server load), else the client -``poll_interval`` setting. A quick task resolves in ~20ms; a long one settles to -the advertised cadence. -""" - -from __future__ import annotations - -import pytest -from fastmcp_tasks.client import MIN_POLL_INTERVAL, _next_poll_delay, _poll_ceiling -from fastmcp_tasks.settings import TasksClientSettings, client_settings -from pydantic import ValidationError - - -@pytest.mark.parametrize("value", [0, -0.5, -1]) -def test_non_positive_poll_interval_setting_is_rejected(value: float): - with pytest.raises(ValidationError): - TasksClientSettings(poll_interval=value) - - -def test_positive_poll_interval_setting_is_accepted(): - settings = TasksClientSettings(poll_interval=0.25) - assert settings.poll_interval == 0.25 - - -@pytest.mark.parametrize("poll_interval_ms", [2000, 30_000]) -def test_advertised_interval_caps_the_ramp(poll_interval_ms: int): - """An advertised interval is the ceiling the ramp tops out at.""" - assert _poll_ceiling(poll_interval_ms) == poll_interval_ms / 1000 - - -def test_large_advertised_interval_is_honored(): - day_ms = 24 * 60 * 60 * 1000 - assert _poll_ceiling(day_ms) == 24 * 60 * 60 - - -@pytest.mark.parametrize("poll_interval_ms", [None, 0, -1, -5000]) -def test_absent_or_hostile_interval_falls_back_to_setting(poll_interval_ms): - """An absent, zero, or negative server value cannot spin the client: use the setting.""" - assert _poll_ceiling(poll_interval_ms) == client_settings.poll_interval - - -def test_ramp_doubles_from_floor_up_to_advertised_ceiling(): - """Even with an advertised interval, the poll ramps fast then caps at it.""" - ceiling_ms = 500 # 0.5s ceiling - delays = [] - backoff = MIN_POLL_INTERVAL - for _ in range(7): - delay, backoff = _next_poll_delay(ceiling_ms, backoff) - delays.append(delay) - - assert delays == [0.02, 0.04, 0.08, 0.16, 0.32, 0.5, 0.5] - - -def test_first_delay_is_the_floor(): - delay, backoff = _next_poll_delay(30_000, MIN_POLL_INTERVAL) - assert delay == MIN_POLL_INTERVAL - assert backoff == MIN_POLL_INTERVAL * 2 diff --git a/tests/tasks/client/test_task_tracing.py b/tests/tasks/client/test_task_tracing.py deleted file mode 100644 index 6d9c9bd70..000000000 --- a/tests/tasks/client/test_task_tracing.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Client OpenTelemetry tracing for the task management wire calls. - -Task submission and the `tasks/get`/`update`/`cancel` polling requests each get -a FastMCP client span and propagate trace context, so a tasked call is traced -end to end the same way a synchronous one is — its server spans nest under the -client spans rather than starting fresh trace roots. -""" - -from __future__ import annotations - -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter - -from fastmcp import Client, FastMCP -from fastmcp_tasks import TasksExtension - - -async def test_tasked_call_creates_client_spans(trace_exporter: InMemorySpanExporter): - mcp = FastMCP("traced-tasks") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def double(n: int) -> int: - return n * 2 - - async with Client(mcp, mode="auto") as client: - result = await client.call_tool("double", {"n": 21}) - assert result.data == 42 - - names = [s.name for s in trace_exporter.get_finished_spans()] - # The tasked submission and at least one poll each produced a client span. - assert "tools/call double" in names - assert "tasks/get" in names diff --git a/tests/tasks/client/test_transparent_tasks.py b/tests/tasks/client/test_transparent_tasks.py deleted file mode 100644 index 0c7dd046b..000000000 --- a/tests/tasks/client/test_transparent_tasks.py +++ /dev/null @@ -1,233 +0,0 @@ -"""The transparent client task flow over a real in-memory connection. - -A real `Client(server, mode="auto")` calls a `task=True` tool; the server runs it -as a task and answers `tools/call` with a `CreateTaskResult`; the client's -auto-registered tasks extension resolves it by polling `tasks/get` to completion. -The caller of `call_tool` sees only the tool's real result — never that the call -was tasked. This is the whole point of the client half. -""" - -from __future__ import annotations - -import asyncio -from dataclasses import dataclass - -import mcp_types -import pytest -from mcp.shared.exceptions import MCPError - -from fastmcp import Context, FastMCP -from fastmcp.client import Client -from fastmcp.exceptions import ToolError -from fastmcp_tasks import TasksExtension, call_tool_task - - -@pytest.fixture -def task_server() -> FastMCP: - mcp = FastMCP("transparent-tasks") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def multiply(a: int, b: int) -> int: - await asyncio.sleep(0.01) - return a * b - - @mcp.tool(task=True) - async def boom() -> str: - raise ValueError("kaboom") - - @mcp.tool(task=True) - async def slow() -> str: - await asyncio.sleep(5) - return "done" - - return mcp - - -async def test_call_tool_timeout_bounds_total_task_drive(task_server: FastMCP): - """A per-call timeout bounds the whole tasked drive, not just one poll. - - The tool runs far longer than the timeout while each individual poll answers - instantly; the transparent path must still abort once total execution passes - the deadline, matching the synchronous `tools/call` timeout contract. - """ - async with Client(task_server, mode="auto") as client: - with pytest.raises((TimeoutError, MCPError)): - await client.call_tool("slow", {}, timeout=0.3) - - -async def test_call_tool_transparently_completes_a_task(task_server: FastMCP): - """call_tool returns the tool's real result; the caller never sees a task.""" - async with Client(task_server, mode="auto") as client: - result = await client.call_tool("multiply", {"a": 6, "b": 7}) - - assert result.data == 42 - - -async def test_call_tool_mcp_returns_completed_result(task_server: FastMCP): - """call_tool_mcp resolves the tasked call into an ordinary CallToolResult.""" - async with Client(task_server, mode="auto") as client: - result = await client.call_tool_mcp("multiply", {"a": 3, "b": 4}) - - assert result.structured_content == {"result": 12} - assert not result.is_error - - -async def test_failed_task_raises_tool_error(task_server: FastMCP): - """A task whose tool raises surfaces as a ToolError through call_tool.""" - async with Client(task_server, mode="auto") as client: - with pytest.raises(ToolError, match="kaboom"): - await client.call_tool("boom", {}) - - -async def test_call_tool_task_forwards_requested_version(): - """`call_tool_task(..., version=...)` tasks the requested version, not the highest.""" - mcp = FastMCP("versioned-task-client") - mcp.add_extension(TasksExtension()) - - @mcp.tool(name="pick", version="1.0", task=True) - async def pick_v1() -> str: - return "v1" - - @mcp.tool(name="pick", version="2.0", task=True) - async def pick_v2() -> str: - return "v2" - - async with Client(mcp, mode="auto") as client: - task = await call_tool_task(client, "pick", version="1.0") - result = await task.result() - - assert result.data == "v1" - - -async def test_raw_create_task_result_is_exposed(task_server: FastMCP): - """The raw claimed CreateTaskResult is reachable via the session/handle path.""" - async with Client(task_server, mode="auto") as client: - task = await call_tool_task(client, "multiply", {"a": 2, "b": 5}) - # The raw claimed shape is exposed on the handle. - assert task.create_result.result_type == "task" - assert task.create_result.status == "working" - assert isinstance(task.task_id, str) and task.task_id - - result = await task.result() - assert result.data == 10 - - -async def test_legacy_client_never_tasks(task_server: FastMCP): - """A legacy-era client never negotiates the capability, so nothing is tasked. - - The optional-mode tool simply runs synchronously and returns its result - directly (no CreateTaskResult on the wire). - """ - async with Client(task_server, mode="legacy") as client: - result = await client.call_tool("multiply", {"a": 8, "b": 9}) - - assert result.data == 72 - - -# --- In-task input over the wire ------------------------------------------- - - -@dataclass -class DinnerPrefs: - cuisine: str - vegetarian: bool - - -def _elicit_request(message: str) -> mcp_types.ElicitRequest: - return mcp_types.ElicitRequest( - params=mcp_types.ElicitRequestFormParams( - message=message, - requested_schema={ - "type": "object", - "properties": { - "cuisine": {"type": "string"}, - "vegetarian": {"type": "boolean"}, - }, - "required": ["cuisine", "vegetarian"], - }, - ) - ) - - -@pytest.fixture -def guard_server() -> FastMCP: - mcp = FastMCP("guard-tasks") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def plan_dinner( - ctx: Context, - ) -> str | mcp_types.InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={"prefs": _elicit_request("What's for dinner?")}, - ) - answer = responses["prefs"] - assert isinstance(answer, mcp_types.ElicitResult) - assert answer.content is not None - veg = "vegetarian " if answer.content["vegetarian"] else "" - return f"Tonight: a {veg}{answer.content['cuisine']} dinner!" - - return mcp - - -async def test_in_task_input_answered_transparently(guard_server: FastMCP): - """A guard task that asks for input is answered via the elicitation handler.""" - - async def handle_elicitation(message, response_type, params, context): - return DinnerPrefs(cuisine="Thai", vegetarian=True) - - client = Client(guard_server, mode="auto", elicitation_handler=handle_elicitation) - async with client: - result = await client.call_tool("plan_dinner", {}) - - assert result.data == "Tonight: a vegetarian Thai dinner!" - - -async def test_in_task_input_without_handler_errors(guard_server: FastMCP): - """A guard task with no elicitation handler surfaces a clear error.""" - async with Client(guard_server, mode="auto") as client: - with pytest.raises(ToolError, match="no elicitation handler"): - await client.call_tool("plan_dinner", {}) - - -async def test_call_tool_timeout_bounds_a_stalled_elicitation(guard_server: FastMCP): - """A stalled elicitation handler cannot outlast the call's timeout. - - The deadline covers the whole drive, elicitation callbacks included: a - handler that hangs must abort the tasked call once `timeout=N` elapses, - matching the synchronous path rather than blocking forever inside the - callback. - """ - - async def slow_elicitation(message, response_type, params, context): - await asyncio.sleep(5) - return DinnerPrefs(cuisine="Thai", vegetarian=True) - - client = Client(guard_server, mode="auto", elicitation_handler=slow_elicitation) - async with client: - with pytest.raises((TimeoutError, ToolError, MCPError)): - await client.call_tool("plan_dinner", {}, timeout=0.3) - - -async def test_in_task_input_answered_by_handler_set_after_construction( - guard_server: FastMCP, -): - """An elicitation handler set via set_elicitation_callback reaches in-task input. - - The tasks client extension is built at construction; set_elicitation_callback - must rebuild it so a later-configured handler still answers a task's input. - """ - - async def handle_elicitation(message, response_type, params, context): - return DinnerPrefs(cuisine="Thai", vegetarian=True) - - client = Client(guard_server, mode="auto") - client.set_elicitation_callback(handle_elicitation) - async with client: - result = await client.call_tool("plan_dinner", {}) - - assert result.data == "Tonight: a vegetarian Thai dinner!" diff --git a/tests/tasks/server/conftest.py b/tests/tasks/server/conftest.py deleted file mode 100644 index b1dc7f98a..000000000 --- a/tests/tasks/server/conftest.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Configuration for server task tests.""" - -import secrets -from pathlib import Path - -import pytest - -from fastmcp.utilities.tests import temporary_settings - - -@pytest.fixture(autouse=True) -def reset_docket_memory_server(): - """Reset the shared memory:// Docket server between tests. - - Docket keeps a process-wide ``Docket._memory_server`` singleton for - ``memory://`` backends. It persists across tests and across event loops, so a - test that inherits a stale server from a previous loop can fail (e.g. - ``tasks/get`` raising ``TypeError`` from the dead client). Clearing it before - and after each test keeps the task suite isolation-safe rather than - order-dependent. - """ - from docket import Docket - - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - -@pytest.fixture(autouse=True) -def isolate_settings_home(_settings_home_root: Path): - """Task-local override of the repo-wide ``isolate_settings_home`` fixture. - - Docket configuration moved out of core ``Settings`` into - ``fastmcp_tasks.settings.DocketSettings``, so the repo-wide fixture's - ``docket__*`` kwargs no longer resolve against core settings. This - override keeps the per-test settings-home isolation while dropping the - removed docket kwargs. - """ - test_home = _settings_home_root / secrets.token_hex(8) - test_home.mkdir() - - with temporary_settings(home=test_home, client_disconnect_timeout=1): - yield diff --git a/tests/tasks/server/test_context_background_task.py b/tests/tasks/server/test_context_background_task.py deleted file mode 100644 index bdc7b939d..000000000 --- a/tests/tasks/server/test_context_background_task.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Tests for Context background task support (SEP-2663 tasks). - -Covers the Context API surface in a background task (unit tests, no Redis -needed) and end-to-end background-task behavior driven in-process through the -shared task helpers: progress reporting, context wiring, access-token -availability, and poll-based in-task elicitation. - -A SEP-2663 worker has no live session and no back-channel: ``ctx.session`` is -unavailable, and elicitation is polled (the worker parks an input request that -the client answers via ``tasks/update``). -""" - -from __future__ import annotations - -import gc -from contextlib import AsyncExitStack -from typing import Any, cast -from unittest.mock import AsyncMock - -import pytest -from fastmcp_tasks.context import ( - _task_sessions, - get_task_session, - register_task_session, -) -from mcp import ServerSession -from mcp.server.auth.middleware.auth_context import auth_context_var -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp_types import ( - ClientCapabilities, - Implementation, - InitializeRequestParams, -) - -from fastmcp import FastMCP -from fastmcp.exceptions import ToolError -from fastmcp.server.auth import AccessToken -from fastmcp.server.context import Context -from fastmcp.server.dependencies import get_access_token -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - running_task_server, - submit_task, - wait_for_task, -) - -# ============================================================================= -# Unit tests: Context API surface (no Redis/Docket needed) -# ============================================================================= - - -class TestContextBackgroundTaskSupport: - """Tests for Context.is_background_task and related functionality.""" - - def test_context_not_background_task_by_default(self): - """Context should not be a background task by default.""" - mcp = FastMCP("test") - ctx = Context(mcp) - assert ctx.is_background_task is False - assert ctx.task_id is None - - def test_context_is_background_task_when_task_id_provided(self): - """Context should be a background task when task_id is provided.""" - mcp = FastMCP("test") - ctx = Context(mcp, task_id="test-task-123") - assert ctx.is_background_task is True - assert ctx.task_id == "test-task-123" - - def test_context_task_id_is_readonly(self): - """task_id should be a read-only property.""" - mcp = FastMCP("test") - ctx = Context(mcp, task_id="test-task-123") - with pytest.raises(AttributeError): - setattr(ctx, "task_id", "new-id") - - -async def test_live_task_session_is_released_on_connection_disconnect(): - """A registered in-process task session is dropped when its connection - exit stack unwinds.""" - _task_sessions.clear() - - class MockConnection: - def __init__(self) -> None: - self.state: dict[str, object] = {} - self.exit_stack = AsyncExitStack() - - class MockSession: - def __init__(self, connection: MockConnection) -> None: - self._connection = connection - - connection = MockConnection() - session = MockSession(connection) - async with connection.exit_stack: - register_task_session("session", cast(ServerSession, session)) - session_ref = _task_sessions["session"] - - assert session_ref() is session - assert _task_sessions == {} - - -async def test_connection_cleanup_does_not_remove_replacement_session(): - """Registering a replacement session under the same id keeps the newer one.""" - _task_sessions.clear() - - class MockConnection: - def __init__(self) -> None: - self.state: dict[str, object] = {} - self.exit_stack = AsyncExitStack() - - class MockSession: - def __init__(self, connection: MockConnection | None = None) -> None: - self._connection = connection - - connection = MockConnection() - old_session = MockSession(connection) - new_session = MockSession() - async with connection.exit_stack: - register_task_session("shared", cast(ServerSession, old_session)) - register_task_session("shared", cast(ServerSession, new_session)) - - assert get_task_session("shared") is new_session - _task_sessions.clear() - - -def test_replaced_task_session_is_not_removed_by_old_weakref(): - """A stale weakref for a replaced session does not evict the new session.""" - _task_sessions.clear() - - class MockSession: - pass - - old_session = MockSession() - new_session = MockSession() - register_task_session("shared", cast(ServerSession, old_session)) - old_ref = _task_sessions["shared"] - register_task_session("shared", cast(ServerSession, new_session)) - - del old_session - gc.collect() - - assert old_ref() is None - assert get_task_session("shared") is new_session - - -class TestContextSessionProperty: - """Tests for Context.session property in different modes.""" - - def test_session_raises_when_no_session_available(self): - """session should raise RuntimeError when no session is available.""" - mcp = FastMCP("test") - ctx = Context(mcp) # No session, not a background task - - with pytest.raises(RuntimeError, match="session is not available"): - _ = ctx.session - - def test_session_uses_stored_session_in_background_task(self): - """session should use the stored session in background task mode.""" - mcp = FastMCP("test") - - class MockSession: - _fastmcp_state_prefix = "test-session" - - mock_session = MockSession() - ctx = Context( - mcp, session=cast(ServerSession, mock_session), task_id="test-task-123" - ) - - assert ctx.session is mock_session - - def test_session_uses_stored_session_during_on_initialize(self): - """session should use the stored session during on_initialize.""" - mcp = FastMCP("test") - - class MockSession: - _fastmcp_state_prefix = "test-session" - - mock_session = MockSession() - ctx = Context(mcp, session=cast(ServerSession, mock_session)) - - assert ctx.session is mock_session - - -class TestContextBackgroundTaskLogging: - """Tests for per-session log gating in background task mode.""" - - def _make_task_context( - self, mcp: FastMCP, session_id: str - ) -> tuple[Context, AsyncMock]: - send_log_message = AsyncMock() - - class MockConnection: - def __init__(self, session_id: str) -> None: - self.session_id = session_id - - class MockSession: - def __init__(self, session_id: str) -> None: - self._connection = MockConnection(session_id) - self._fastmcp_state_prefix = session_id - self.send_log_message = send_log_message - - session = MockSession(session_id) - ctx = Context( - mcp, session=cast(ServerSession, session), task_id="test-task-123" - ) - return ctx, send_log_message - - async def test_background_task_honors_session_level(self): - """A background task has a stored session but no request context; the - per-session minimum registered via logging/setLevel must still gate - its logs, so sub-threshold messages are not sent to the client.""" - mcp = FastMCP("test") - session_id = "session-abc" - mcp._client_log_levels[session_id] = "error" - - ctx, send_log_message = self._make_task_context(mcp, session_id) - assert ctx.is_background_task is True - assert ctx.request_context is None - - await ctx.info("info msg") - send_log_message.assert_not_called() - - await ctx.error("error msg") - send_log_message.assert_called_once() - - async def test_background_task_without_session_level_sends_all(self): - """When no per-session level is registered, background-task logs fall - back to the server default (which allows everything by default).""" - mcp = FastMCP("test") - ctx, send_log_message = self._make_task_context(mcp, "session-xyz") - - await ctx.info("info msg") - send_log_message.assert_called_once() - - -class TestContextClientExtensionBackgroundTask: - """Tests for Context.client_supports_extension() in background task mode. - - A background task may carry a stored snapshot session but no request - context. The client's advertised capabilities are preserved on the - session's ``client_params``, so extension detection reads from the session - rather than gating on ``request_context``. - """ - - def _make_task_context( - self, mcp: FastMCP, extensions: dict[str, dict[str, Any]] | None - ) -> Context: - capabilities = ClientCapabilities(extensions=extensions) - client_params = InitializeRequestParams( - protocol_version="2025-06-18", - capabilities=capabilities, - client_info=Implementation(name="test-client", version="1.0"), - ) - - class MockSession: - _fastmcp_state_prefix = "session-ext" - - def __init__(self) -> None: - self.client_params = client_params - - session = MockSession() - return Context( - mcp, session=cast(ServerSession, session), task_id="test-task-ext" - ) - - def test_background_task_detects_advertised_extension(self): - """The stored session preserves the client's initialize params, so an - advertised extension is detected even with no request context.""" - mcp = FastMCP("test") - ctx = self._make_task_context(mcp, {"ext-abc": {}}) - - assert ctx.is_background_task is True - assert ctx.request_context is None - assert ctx.client_supports_extension("ext-abc") is True - assert ctx.client_supports_extension("ext-missing") is False - - def test_background_task_no_extensions_returns_false(self): - """When the client advertised no extensions, detection returns False.""" - mcp = FastMCP("test") - ctx = self._make_task_context(mcp, None) - - assert ctx.client_supports_extension("ext-abc") is False - - def test_no_session_returns_false(self): - """With no session available at all (e.g. distributed worker), the - method degrades to False rather than raising.""" - mcp = FastMCP("test") - ctx = Context(mcp, task_id="test-task-ext") - - assert ctx.client_supports_extension("ext-abc") is False - - -class TestContextElicitBackgroundTask: - """Tests for Context.elicit() in background task mode. - - Imperative elicitation is not supported inside a background task: the worker - never blocks on a client round-trip. A task gathers input with the guard - pattern (return an ``InputRequiredResult``), so ``ctx.elicit()`` in a task - fails fast with guidance rather than parking a worker. - """ - - async def test_elicit_raises_with_guard_guidance(self): - """elicit() inside a background task raises a ToolError pointing to the - guard/return pattern (InputRequiredResult).""" - mcp = FastMCP("test") - ctx = Context(mcp, task_id="test-task-123") - - class MockSession: - _fastmcp_state_prefix = "test-session" - - ctx._session = cast(ServerSession, MockSession()) - - with pytest.raises(ToolError, match="InputRequiredResult"): - await ctx.elicit("Need input", str) - - -class TestContextDocumentation: - """Tests to verify Context documentation and API surface.""" - - def test_is_background_task_has_docstring(self): - """is_background_task property should have documentation.""" - assert Context.is_background_task.__doc__ is not None - assert "background task" in Context.is_background_task.__doc__.lower() - - def test_task_id_has_docstring(self): - """task_id property should have documentation.""" - assert Context.task_id.fget.__doc__ is not None - assert "task ID" in Context.task_id.fget.__doc__ - - def test_session_has_docstring(self): - """session property should document background task support.""" - assert Context.session.fget.__doc__ is not None - assert "background task" in Context.session.fget.__doc__.lower() - - -# ============================================================================= -# Integration tests: in-process SEP-2663 tasks via the shared helpers -# ============================================================================= - - -class TestBackgroundTaskIntegration: - """End-to-end background task context, driven in-process via the helpers.""" - - async def test_report_progress_in_background_task(self): - """report_progress() should complete without error in a background task.""" - mcp = FastMCP("progress-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def progress_tool(ctx: Context) -> str: - await ctx.report_progress(0, 100, "Starting...") - await ctx.report_progress(50, 100, "Half done") - await ctx.report_progress(100, 100, "Complete") - return "done" - - async with running_task_server(mcp): - created = await submit_task(mcp, "progress_tool", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "done"} - - async def test_context_wiring_in_background_task(self): - """A worker Context is wired as a background task with no live session.""" - mcp = FastMCP("wiring-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def verify_wiring(ctx: Context) -> dict[str, bool]: - session_unavailable = False - try: - _ = ctx.session - except RuntimeError: - session_unavailable = True - return { - "task_id_set": ctx.task_id is not None, - "is_background": ctx.is_background_task, - "no_request_context": ctx.request_context is None, - "session_unavailable": session_unavailable, - } - - async with running_task_server(mcp): - created = await submit_task(mcp, "verify_wiring", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == { - "task_id_set": True, - "is_background": True, - "no_request_context": True, - "session_unavailable": True, - } - - async def test_imperative_elicit_fails_with_guard_guidance(self): - """A task=True tool that calls ctx.elicit() errors with guard guidance. - - The ToolError it raises surfaces as a completed is_error result (like any - raised tool error, SEP-2663), never parking a worker on a round-trip. - """ - mcp = FastMCP("elicit-forbidden") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def ask_name(ctx: Context) -> str: - result = await ctx.elicit("What is your name?", str) - return str(result) - - async with running_task_server(mcp): - created = await submit_task(mcp, "ask_name", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["isError"] is True - assert "InputRequiredResult" in final.result["content"][0]["text"] - - -class TestAccessTokenInBackgroundTasks: - """Tests for access token availability in background tasks (#3095). - - The token set at submit time is available inside the worker (via the - captured context snapshot). Async tests run in isolated asyncio tasks, so - ContextVar changes are automatically scoped — no cleanup required. - """ - - async def test_token_round_trips_through_background_task(self): - """E2E: token set at submit time is available inside the worker.""" - mcp = FastMCP("token-roundtrip") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def check_token(ctx: Context) -> str: - token = get_access_token() - if token is None: - return "no-token" - return f"{token.token}|{token.client_id}" - - test_token = AccessToken( - token="roundtrip-jwt", - client_id="test-client", - scopes=["read"], - claims={"sub": "user-1"}, - ) - auth_context_var.set(AuthenticatedUser(test_token)) - - async with running_task_server(mcp): - created = await submit_task(mcp, "check_token", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == { - "result": "roundtrip-jwt|test-client" - } - - async def test_no_token_when_unauthenticated(self): - """E2E: background task gets no token when nothing was set.""" - mcp = FastMCP("no-auth") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def check_token(ctx: Context) -> str: - token = get_access_token() - return "no-token" if token is None else token.token - - async with running_task_server(mcp): - created = await submit_task(mcp, "check_token", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "no-token"} - - -class TestLifespanContextInBackgroundTasks: - """Tests for lifespan_context availability in background tasks (#3095).""" - - def test_lifespan_context_falls_back_to_server_result(self): - """lifespan_context reads from server when request_context is None.""" - mcp = FastMCP("test") - mcp._lifespan_result = {"db": "mock-db-connection", "cache": "mock-cache"} - - ctx = Context(mcp, task_id="test-task") - assert ctx.request_context is None - assert ctx.lifespan_context == { - "db": "mock-db-connection", - "cache": "mock-cache", - } - - def test_lifespan_context_returns_empty_dict_when_no_lifespan(self): - """lifespan_context returns {} when no lifespan is configured.""" - mcp = FastMCP("test") - ctx = Context(mcp, task_id="test-task") - assert ctx.request_context is None - assert ctx.lifespan_context == {} diff --git a/tests/tasks/server/test_custom_subclass_tasks.py b/tests/tasks/server/test_custom_subclass_tasks.py deleted file mode 100644 index cb9657849..000000000 --- a/tests/tasks/server/test_custom_subclass_tasks.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Tests for custom Tool subclasses with task support. - -Verifies that custom Tool subclasses can use background task execution by -setting task_config. SEP-2663 is tools-only, so the removed resource/prompt -subclass cases are gone. -""" - -import asyncio -from typing import Any -from unittest.mock import MagicMock - -import pytest -from fastmcp_tasks.components import ( - add_component_to_docket, - register_component_with_docket, -) -from fastmcp_tasks.models import CreateTaskResult - -from fastmcp import FastMCP -from fastmcp.exceptions import ToolError -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.tasks import TaskConfig -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - _opted_in_request, - auth_scope, - call_tool_without_optin, - run_task, - running_task_server, -) - - -class CustomTool(Tool): - """A custom tool subclass with task support.""" - - task_config: TaskConfig = TaskConfig(mode="optional") - parameters: dict[str, Any] = {"type": "object", "properties": {}} - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - return ToolResult(content=f"Custom tool executed with {arguments}") - - -class CustomToolWithLogic(Tool): - """A custom tool with actual async work.""" - - task_config: TaskConfig = TaskConfig(mode="optional") - parameters: dict[str, Any] = { - "type": "object", - "properties": {"duration": {"type": "integer"}}, - } - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - duration = arguments.get("duration", 0) - await asyncio.sleep(duration * 0.01) # Short sleep for testing - return ToolResult(content=f"Completed after {duration} units") - - -class CustomToolForbidden(Tool): - """A custom tool with task_config forbidden (default).""" - - parameters: dict[str, Any] = {"type": "object", "properties": {}} - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - return ToolResult(content="Sync only") - - -class CustomToolRaisesToolError(Tool): - """A custom tool whose `run` raises a `ToolError`.""" - - task_config: TaskConfig = TaskConfig(mode="optional") - parameters: dict[str, Any] = {"type": "object", "properties": {}} - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - raise ToolError("kaboom") - - -class CustomToolRaisesValueError(Tool): - """A custom tool whose `run` raises a non-FastMCP exception.""" - - task_config: TaskConfig = TaskConfig(mode="optional") - parameters: dict[str, Any] = {"type": "object", "properties": {}} - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - raise ValueError("secret internal detail") - - -@pytest.fixture -def custom_tool_server() -> FastMCP: - """A server with custom tool subclasses.""" - mcp = FastMCP("custom-tool-server") - mcp.add_extension(TasksExtension()) - mcp.add_tool(CustomTool(name="custom_tool", description="A custom tool")) - mcp.add_tool( - CustomToolWithLogic(name="custom_logic", description="Custom tool with logic") - ) - mcp.add_tool( - CustomToolForbidden(name="custom_forbidden", description="No task support") - ) - return mcp - - -async def test_custom_tool_sync_execution(custom_tool_server): - """Custom tool executes synchronously without a tasks opt-in.""" - async with running_task_server(custom_tool_server): - result = await call_tool_without_optin(custom_tool_server, "custom_tool", {}) - assert "Custom tool executed" in result.content[0].text - - -async def test_custom_tool_background_execution(custom_tool_server): - """Custom tool executes as a background task when opted in.""" - async with running_task_server(custom_tool_server): - final = await run_task(custom_tool_server, "custom_tool", {}) - - assert final.status == "completed" - assert final.result is not None - assert "Custom tool executed" in final.result["content"][0]["text"] - - -async def test_custom_tool_with_arguments(custom_tool_server): - """Custom tool receives arguments correctly in background execution.""" - async with running_task_server(custom_tool_server): - final = await run_task(custom_tool_server, "custom_logic", {"duration": 1}) - - assert final.status == "completed" - assert final.result is not None - assert "Completed after 1 units" in final.result["content"][0]["text"] - - -async def test_custom_tool_forbidden_sync_only(custom_tool_server): - """Custom tool with forbidden mode executes synchronously.""" - async with running_task_server(custom_tool_server): - result = await call_tool_without_optin( - custom_tool_server, "custom_forbidden", {} - ) - assert "Sync only" in result.content[0].text - - -async def test_custom_tool_forbidden_rejects_task(custom_tool_server): - """A forbidden tool runs synchronously even when the client opts in.""" - async with running_task_server(custom_tool_server): - with auth_scope(None), _opted_in_request("custom_forbidden", {}, None): - result = await custom_tool_server.call_tool("custom_forbidden", {}) - assert not isinstance(result, CreateTaskResult) - assert "Sync only" in result.content[0].text - - -async def test_custom_tool_raising_tool_error_completes_with_is_error(): - """A custom Tool that raises `ToolError` is a completed, is_error task. - - Same contract as a raising `FunctionTool`: a raised tool error is a - completed task carrying an `isError` result (never a `failed` task), and a - `ToolError` reaches the client verbatim — matching the synchronous path. - """ - mcp = FastMCP("custom-raise-server") - mcp.add_extension(TasksExtension()) - mcp.add_tool(CustomToolRaisesToolError(name="boom", description="raises")) - - async with running_task_server(mcp): - final = await run_task(mcp, "boom", {}) - - assert final.status == "completed" - assert final.error is None - assert final.result is not None - assert final.result["isError"] is True - assert "kaboom" in final.result["content"][0]["text"] - - -async def test_custom_tool_raising_generic_error_is_masked(): - """A custom Tool's non-FastMCP exception is masked, like the sync path. - - A base `Tool` subclass must route through the same error conversion as a - `FunctionTool`, so `mask_error_details=True` hides the raw exception text - rather than leaking it through Docket's `FAILED` outcome. - """ - mcp = FastMCP("custom-mask-server", mask_error_details=True) - mcp.add_extension(TasksExtension()) - mcp.add_tool(CustomToolRaisesValueError(name="leak", description="raises")) - - async with running_task_server(mcp): - final = await run_task(mcp, "leak", {}) - - assert final.status == "completed" - assert final.error is None - assert final.result is not None - assert final.result["isError"] is True - text = final.result["content"][0]["text"] - assert "secret internal detail" not in text - assert "Error calling tool 'leak'" in text - - -async def test_custom_tool_registers_with_docket(): - """A task-capable custom tool registers its `run` entry point with Docket.""" - tool = CustomTool(name="test", description="test") - mock_docket = MagicMock() - - register_component_with_docket(tool, mock_docket) - - mock_docket.register.assert_called_once() - call_args = mock_docket.register.call_args - assert call_args[1]["names"] == ["tool:test@"] - - -async def test_custom_tool_forbidden_does_not_register(): - """A forbidden custom tool does not register with Docket.""" - tool = CustomToolForbidden(name="test", description="test") - mock_docket = MagicMock() - - register_component_with_docket(tool, mock_docket) - - mock_docket.register.assert_not_called() - - -# ============================================================================== -# Base FastMCPComponent Tests -# ============================================================================== - - -class TestFastMCPComponentDocketMethods: - """Tests for base FastMCPComponent docket integration.""" - - def test_default_task_config_is_forbidden(self): - """Base component defaults to task_config mode='forbidden'.""" - component = FastMCPComponent(name="test") - assert component.task_config.mode == "forbidden" - - def test_register_with_docket_is_noop(self): - """Registering a forbidden base component is a no-op.""" - component = FastMCPComponent(name="test") - mock_docket = MagicMock() - - register_component_with_docket(component, mock_docket) - - mock_docket.register.assert_not_called() - - async def test_add_to_docket_raises_when_forbidden(self): - """add_component_to_docket raises RuntimeError when mode is 'forbidden'.""" - component = FastMCPComponent(name="test") - mock_docket = MagicMock() - - with pytest.raises(RuntimeError, match="task execution not supported"): - await add_component_to_docket(component, mock_docket, None) - - async def test_add_to_docket_raises_not_implemented_when_allowed(self): - """add_component_to_docket raises NotImplementedError for an unknown type.""" - component = FastMCPComponent( - name="test", task_config=TaskConfig(mode="optional") - ) - mock_docket = MagicMock() - - with pytest.raises( - NotImplementedError, match="does not implement add_to_docket" - ): - await add_component_to_docket(component, mock_docket, None) diff --git a/tests/tasks/server/test_extension.py b/tests/tasks/server/test_extension.py deleted file mode 100644 index 44dc7ef9c..000000000 --- a/tests/tasks/server/test_extension.py +++ /dev/null @@ -1,488 +0,0 @@ -"""End-to-end tests for the SEP-2663 `TasksExtension` server adapter. - -Covers the decide-and-task interceptor (forbidden/optional/required modes and the --32021 missing-capability error), the tasks/get|update|cancel handlers, status -mapping, inlined completed results, argument-coercion parity, TTL, and capability -advertisement. Server-side tasks are driven in-process via `task_helpers` because -there is no client task-submission API until Phase 4. -""" - -from __future__ import annotations - -import asyncio -from contextlib import AsyncExitStack -from types import SimpleNamespace -from typing import cast - -import mcp_types -import pytest -from fastmcp_tasks.models import ( - MISSING_REQUIRED_CLIENT_CAPABILITY, - CreateTaskResult, - GetTaskParams, -) -from mcp.server.context import ServerRequestContext -from mcp.server.session import ServerSession -from mcp.shared.exceptions import MCPError - -from fastmcp import Context, FastMCP -from fastmcp.client import Client -from fastmcp.exceptions import ToolError -from fastmcp.server.dependencies import bind_request_context -from fastmcp.tools.base import ToolResult -from fastmcp.utilities.tasks import TASKS_EXTENSION_ID, TaskConfig -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - _opted_in_request, - auth_scope, - call_tool_without_optin, - get_task, - make_access_token, - opt_in_meta, - run_task, - running_task_server, - submit_task, - update_task, - wait_for_task, -) - - -def _tasks_server() -> FastMCP: - mcp = FastMCP("tasks") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def square(n: int) -> int: - return n * n - - @mcp.tool(task=TaskConfig(mode="required")) - async def must_task(n: int) -> int: - return n + 1 - - @mcp.tool - async def plain(n: int) -> int: - return n - 1 - - @mcp.tool(task=True) - async def boom() -> int: - raise ToolError("kaboom") - - return mcp - - -# --------------------------------------------------------------------------- -# Capability advertisement -# --------------------------------------------------------------------------- - - -async def test_capability_advertised_to_modern_client(): - mcp = FastMCP("t") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def t(n: int) -> int: - return n - - async with Client(mcp, mode="auto") as client: - extensions = client.server_capabilities.extensions or {} - assert extensions.get(TASKS_EXTENSION_ID) == {} - - -async def test_capability_absent_without_extension(): - mcp = FastMCP("t") - - @mcp.tool - async def t(n: int) -> int: - return n - - async with Client(mcp, mode="auto") as client: - extensions = client.server_capabilities.extensions or {} - assert TASKS_EXTENSION_ID not in extensions - - -# --------------------------------------------------------------------------- -# Decide-and-task interceptor -# --------------------------------------------------------------------------- - - -async def test_optional_tool_tasks_when_opted_in(): - mcp = _tasks_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "square", {"n": 5}) - assert isinstance(created, CreateTaskResult) - assert created.status == "working" - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"]["result"] == 25 - - -async def test_optional_tool_runs_sync_without_opt_in(): - mcp = _tasks_server() - async with running_task_server(mcp): - result = await call_tool_without_optin(mcp, "square", {"n": 5}) - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": 25} - - -async def test_forbidden_tool_never_tasks_even_with_opt_in(): - mcp = _tasks_server() - async with running_task_server(mcp): - # `plain` is mode=forbidden; opting in must not task it. - result = await submit_task_expecting_sync(mcp, "plain", {"n": 5}) - assert result.structured_content == {"result": 4} - - -async def submit_task_expecting_sync(mcp, name, args): - with auth_scope(None), _opted_in_request(name, args, None): - return await mcp.call_tool(name, args) - - -async def test_required_tool_tasks_when_opted_in(): - mcp = _tasks_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "must_task", {"n": 10}) - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"]["result"] == 11 - - -async def test_required_tool_without_opt_in_raises_missing_capability(): - mcp = _tasks_server() - async with running_task_server(mcp): - with pytest.raises(MCPError) as exc_info: - await call_tool_without_optin(mcp, "must_task", {"n": 1}) - error = exc_info.value.error - assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY - assert error.data == { - "requiredCapabilities": {"extensions": {TASKS_EXTENSION_ID: {}}} - } - - -# --------------------------------------------------------------------------- -# Task id and status -# --------------------------------------------------------------------------- - - -async def test_task_ids_are_server_generated_and_distinct(): - mcp = _tasks_server() - async with running_task_server(mcp): - a = await submit_task(mcp, "square", {"n": 1}) - b = await submit_task(mcp, "square", {"n": 2}) - assert a.task_id != b.task_id - assert len(a.task_id) >= 20 - - -async def test_get_unknown_task_raises_not_found(): - mcp = _tasks_server() - async with running_task_server(mcp): - with pytest.raises(MCPError, match="not found"): - await get_task(mcp, "does-not-exist") - - -async def test_raised_tool_error_completes_with_is_error(): - """A tool that RAISES is a completed task with an is_error result, not failed. - - SEP-2663 reserves `failed` for protocol faults; a raised tool error is the - same `isError` CallToolResult a live tools/call returns (the task path must - return exactly what the underlying request would). - """ - mcp = _tasks_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "boom", {}) - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.error is None - assert final.result is not None - assert final.result["isError"] is True - assert "kaboom" in final.result["content"][0]["text"] - - -async def test_raised_generic_error_is_masked_without_ctx_param(): - """A non-FastMCP exception is masked even when the tool takes no `ctx`. - - Error masking is the server's `_mask_error_details` policy, which the task - error path must resolve through the worker-server resolver — not the active - `Context`. A tool that never requests `ctx` has no active context when it - raises, so a context-based lookup would silently fall back to the global - default and leak the raw exception text. - """ - mcp = FastMCP("masked-task-server", mask_error_details=True) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def leak() -> int: - raise ValueError("secret internal detail") - - async with running_task_server(mcp): - final = await run_task(mcp, "leak", {}) - - assert final.status == "completed" - assert final.result is not None - assert final.result["isError"] is True - text = final.result["content"][0]["text"] - assert "secret internal detail" not in text - assert "Error calling tool 'leak'" in text - - -# --------------------------------------------------------------------------- -# Argument coercion parity -# --------------------------------------------------------------------------- - - -async def test_task_arguments_are_coerced_like_sync_path(): - mcp = _tasks_server() - async with running_task_server(mcp): - # "6" coerces to int 6 exactly as the synchronous path would. - final = await run_task(mcp, "square", {"n": "6"}) - assert final.result is not None - assert final.result["structuredContent"]["result"] == 36 - - -async def test_invalid_task_arguments_reject_at_submission(): - mcp = _tasks_server() - async with running_task_server(mcp): - with pytest.raises(Exception): - await submit_task(mcp, "square", {"n": "not-a-number"}) - - -# --------------------------------------------------------------------------- -# TTL / poll interval -# --------------------------------------------------------------------------- - - -async def test_create_and_get_carry_ttl_and_poll_interval(): - mcp = _tasks_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "square", {"n": 3}) - assert created.ttl_ms is not None and created.ttl_ms > 0 - assert created.poll_interval_ms == 5000 - got = await get_task(mcp, created.task_id) - assert got.ttl_ms == created.ttl_ms - assert got.poll_interval_ms == 5000 - - -# --------------------------------------------------------------------------- -# Cancellation -# --------------------------------------------------------------------------- - - -async def test_cancel_transitions_task_to_cancelled(): - mcp = FastMCP("t") - mcp.add_extension(TasksExtension()) - release = asyncio.Event() - - @mcp.tool(task=True) - async def slow() -> str: - await release.wait() - return "done" - - async with running_task_server(mcp): - created = await submit_task(mcp, "slow", {}) - ack = await cancel_and_release(mcp, created.task_id, release) - assert ack is not None - final = await wait_for_task( - mcp, created.task_id, target_states=frozenset({"cancelled", "completed"}) - ) - assert final.status in {"cancelled", "completed"} - - -async def cancel_and_release(mcp, task_id, release): - from tests.tasks.task_helpers import cancel_task - - ack = await cancel_task(mcp, task_id) - release.set() - return ack - - -# --------------------------------------------------------------------------- -# Serve-time guard -# --------------------------------------------------------------------------- - - -async def test_task_tool_without_extension_fails_at_serve_time(): - mcp = FastMCP("t") - - @mcp.tool(task=True) - async def t(n: int) -> int: - return n - - with pytest.raises(RuntimeError, match="tasks extension"): - async with mcp._lifespan_manager(): - pass - - -# --------------------------------------------------------------------------- -# Auth-scoped isolation -# --------------------------------------------------------------------------- - - -async def test_tasks_isolated_across_auth_scopes(): - mcp = _tasks_server() - alice = make_access_token("alice") - bob = make_access_token("bob") - async with running_task_server(mcp): - created = await submit_task(mcp, "square", {"n": 4}, access_token=alice) - # Alice sees her task. - mine = await get_task(mcp, created.task_id, access_token=alice) - assert mine.task_id == created.task_id - # Bob cannot: a cross-scope id is indistinguishable from missing. - with pytest.raises(MCPError, match="not found"): - await get_task(mcp, created.task_id, access_token=bob) - - -# --------------------------------------------------------------------------- -# Protocol-era gating of the tasking decision -# --------------------------------------------------------------------------- - - -async def test_legacy_era_opt_in_is_ignored(): - """A handshake-era request cannot be tasked, even with the _meta opt-in. - - The SDK strips `capabilities.extensions` from pre-2026 handshakes, so a - legacy client can never have negotiated the tasks extension — a stray - per-request opt-in on a legacy connection is treated as absent and an - `optional` tool runs synchronously. - """ - mcp = _tasks_server() - async with running_task_server(mcp): - srctx = ServerRequestContext( - session=cast(ServerSession, SimpleNamespace()), - lifespan_context={}, - protocol_version="2025-06-18", - method="tools/call", - params={"name": "square", "arguments": {"n": 3}, "_meta": opt_in_meta()}, - ) - with bind_request_context(srctx): - result = await mcp.call_tool("square", {"n": 3}) - assert isinstance(result, ToolResult) - - -async def test_legacy_era_required_tool_raises_missing_capability(): - """`required` tools refuse legacy-era calls with -32021 even when opted in.""" - mcp = _tasks_server() - async with running_task_server(mcp): - srctx = ServerRequestContext( - session=cast(ServerSession, SimpleNamespace()), - lifespan_context={}, - protocol_version="2025-06-18", - method="tools/call", - params={ - "name": "must_task", - "arguments": {"n": 3}, - "_meta": opt_in_meta(), - }, - ) - with bind_request_context(srctx): - with pytest.raises(MCPError) as exc_info: - await mcp.call_tool("must_task", {"n": 3}) - assert exc_info.value.error.code == -32021 - - -# --------------------------------------------------------------------------- -# Worker-hook lifecycle across multiple servers -# --------------------------------------------------------------------------- - - -async def test_worker_hooks_survive_sibling_server_shutdown(): - """One server's shutdown must not strand another server's workers. - - The worker-side hooks core exposes are process-global; two sibling servers - each running a TasksExtension refcount them, so the hooks clear only when - the last extension lifespan exits. - """ - from fastmcp.server import dependencies as core_dependencies - - server_a = _tasks_server() - server_b = _tasks_server() - - async with AsyncExitStack() as stack_b: - await stack_b.enter_async_context(server_b._lifespan_manager()) - async with AsyncExitStack() as stack_a: - await stack_a.enter_async_context(server_a._lifespan_manager()) - assert core_dependencies._background_context_factory is not None - # Server A has shut down; server B's workers still need the hooks. - assert core_dependencies._background_context_factory is not None - # The last extension exited; hooks are cleared. - assert core_dependencies._background_context_factory is None - - -# --------------------------------------------------------------------------- -# Compliance: -32021 on task methods for non-declaring clients (SEP-2663) -# --------------------------------------------------------------------------- - - -def test_missing_capability_code_is_the_protocol_value(): - """The code must track the SDK, not an early SEP-2663 draft. - - It shipped hardcoded as -32003, which no client recognizes: SEP-2575 - assigns -32021 to MissingRequiredClientCapability. - """ - assert MISSING_REQUIRED_CLIENT_CAPABILITY == -32021 - - -async def test_task_method_without_capability_raises_missing_capability(): - """tasks/get from a client that did not declare the extension gets -32021.""" - mcp = _tasks_server() - extension = cast(TasksExtension, mcp._extensions[TASKS_EXTENSION_ID]) - # A request context with no tasks capability in its _meta. - srctx = ServerRequestContext( - session=cast(ServerSession, SimpleNamespace()), - lifespan_context={}, - protocol_version="2026-07-28", - method="tasks/get", - params={"taskId": "whatever"}, - ) - params = GetTaskParams.model_validate({"taskId": "whatever"}) - async with running_task_server(mcp): - with pytest.raises(MCPError) as exc_info: - await extension._handle_get(srctx, params) - assert exc_info.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY - - -# --------------------------------------------------------------------------- -# Compliance: concurrent tasks/update must not enqueue two next legs -# --------------------------------------------------------------------------- - - -async def test_concurrent_update_enqueues_a_single_next_leg(): - """Two racing tasks/update answers re-enter the task exactly once.""" - calls: list[int] = [] - mcp = FastMCP("race") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def guard(ctx: Context) -> str | mcp_types.InputRequiredResult: - calls.append(1) - if ctx.input_responses is None: - req = mcp_types.ElicitRequest( - params=mcp_types.ElicitRequestFormParams( - message="?", requested_schema={"type": "object"} - ) - ) - return mcp_types.InputRequiredResult( - result_type="input_required", input_requests={"k": req} - ) - return "done" - - async with running_task_server(mcp): - created = await submit_task(mcp, "guard", {}) - parked = await wait_for_task( - mcp, created.task_id, target_states=frozenset({"input_required"}) - ) - assert parked.input_requests is not None - key = next(iter(parked.input_requests)) - answer = {key: {"action": "accept", "content": {}}} - # Fire two identical updates concurrently. - await asyncio.gather( - update_task(mcp, created.task_id, answer), - update_task(mcp, created.task_id, answer), - return_exceptions=True, - ) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - # Leg 1 (park) + exactly one re-entered leg 2 — never a third from a double - # enqueue. - assert calls == [1, 1] diff --git a/tests/tasks/server/test_guard_reentrant.py b/tests/tasks/server/test_guard_reentrant.py deleted file mode 100644 index fb4cc52a7..000000000 --- a/tests/tasks/server/test_guard_reentrant.py +++ /dev/null @@ -1,443 +0,0 @@ -"""The guard-pattern reentrant loop driven inside a background task. - -A `task=True` tool that *returns* an `InputRequiredResult` (rather than awaiting -`ctx.elicit()`) is the same guard authoring model FastMCP uses foreground. As a -task, the worker drives the multi-round-trip itself: it parks the request on the -poll surface, the client answers via `tasks/update`, and the tool is re-invoked -with the answer on `ctx.input_responses` — identical to the foreground contract, -only the transport differs. These tests exercise that loop end-to-end through -the real interceptor and handlers via `task_helpers`. -""" - -from __future__ import annotations - -import asyncio -from typing import Any - -import mcp_types -from fastmcp_tasks.context import get_task_scope -from fastmcp_tasks.input_store import acquire_update_lock, release_update_lock -from mcp.shared.exceptions import MCPError -from mcp_types import INTERNAL_ERROR - -from fastmcp import Context, FastMCP -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - cancel_task, - get_task, - running_task_server, - submit_task, - update_task, - wait_for_task, -) - - -def _elicit_request(message: str) -> mcp_types.ElicitRequest: - return mcp_types.ElicitRequest( - params=mcp_types.ElicitRequestFormParams( - message=message, - requested_schema={ - "type": "object", - "properties": {"value": {"type": "string"}}, - }, - ) - ) - - -def _answer(responses: mcp_types.InputResponses, key: str) -> str: - """Read the string value a client accepted for `key` (test helper).""" - result = responses[key] - assert isinstance(result, mcp_types.ElicitResult) - assert result.content is not None - return str(result.content["value"]) - - -def _input_required( - requests: dict[str, mcp_types.ElicitRequest], - request_state: str | None = None, -) -> mcp_types.InputRequiredResult: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests=requests, - request_state=request_state, - ) - - -def _key_asking(input_requests: dict[str, Any], message: str) -> str: - """The surfaced key whose parked request asks *message*.""" - for key, payload in input_requests.items(): - if payload["params"]["message"] == message: - return key - raise AssertionError(f"no parked request asks {message!r}") - - -async def _park_key(mcp: FastMCP, task_id: str) -> str: - parked = await wait_for_task( - mcp, task_id, target_states=frozenset({"input_required"}) - ) - assert parked.status == "input_required" - assert parked.input_requests is not None - return next(iter(parked.input_requests)) - - -async def test_cancel_parked_task_reports_cancelled_and_refuses_resume(): - """Cancelling an `input_required` task actually cancels it. - - A parked guard leg's Docket execution is already COMPLETED, so cancelling - only that execution would leave `tasks/get` reporting `input_required` - forever and let a later `tasks/update` resume the task. The logical - cancellation marker must make `tasks/get` report `cancelled` and turn a - subsequent answer into a no-op that never re-enters the tool. - """ - mcp = FastMCP("guard-cancel") - mcp.add_extension(TasksExtension()) - - ran_after_cancel = False - - @mcp.tool(task=True) - async def greet(ctx: Context) -> str | mcp_types.InputRequiredResult: - nonlocal ran_after_cancel - responses = ctx.input_responses - if responses is None: - return _input_required({"name": _elicit_request("Your name?")}) - ran_after_cancel = True - return f"Hello, {_answer(responses, 'name')}!" - - async with running_task_server(mcp): - created = await submit_task(mcp, "greet", {}) - key = await _park_key(mcp, created.task_id) - - await cancel_task(mcp, created.task_id) - cancelled = await get_task(mcp, created.task_id) - assert cancelled.status == "cancelled" - - # Answering a cancelled task is an idempotent no-op: it must not resume. - await update_task( - mcp, - created.task_id, - {key: {"action": "accept", "content": {"value": "Ada"}}}, - ) - still_cancelled = await get_task(mcp, created.task_id) - assert still_cancelled.status == "cancelled" - - assert ran_after_cancel is False - - -async def test_guard_return_single_round_completes(): - """A tool that returns InputRequiredResult once is driven to completion.""" - mcp = FastMCP("guard") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def greet(ctx: Context) -> str | mcp_types.InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _input_required({"name": _elicit_request("Your name?")}) - return f"Hello, {_answer(responses, 'name')}!" - - async with running_task_server(mcp): - created = await submit_task(mcp, "greet", {}) - key = await _park_key(mcp, created.task_id) - await update_task( - mcp, - created.task_id, - {key: {"action": "accept", "content": {"value": "Ada"}}}, - ) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "Hello, Ada!"} - - -async def test_guard_return_multiple_rounds_use_distinct_keys(): - """A tool that asks twice surfaces distinct keys across rounds (SEP-2663 L350). - - The second round's key must differ from the first's — a client that - deduplicates by key must not suppress the second ask. Cross-round state - travels through `request_state` (each leg's `input_responses` holds only - that leg's answers, matching the foreground guard contract). - """ - mcp = FastMCP("guard") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def full_name(ctx: Context) -> str | mcp_types.InputRequiredResult: - responses = ctx.input_responses - if responses is None: - # Round 1: ask for the first name. - return _input_required({"first": _elicit_request("First name?")}) - if ctx.request_state is None: - # Round 2: carry the first name forward in request_state, ask last. - return _input_required( - {"last": _elicit_request("Last name?")}, - request_state=_answer(responses, "first"), - ) - # Round 3: request_state holds the first name; responses holds the last. - return f"{ctx.request_state} {_answer(responses, 'last')}" - - async with running_task_server(mcp): - created = await submit_task(mcp, "full_name", {}) - key1 = await _park_key(mcp, created.task_id) - await update_task( - mcp, - created.task_id, - {key1: {"action": "accept", "content": {"value": "Ada"}}}, - ) - key2 = await _park_key(mcp, created.task_id) - assert key2 != key1 - await update_task( - mcp, - created.task_id, - {key2: {"action": "accept", "content": {"value": "Lovelace"}}}, - ) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "Ada Lovelace"} - - -async def test_non_guard_tool_runs_once(): - """A tool that never asks for input completes in a single invocation.""" - calls: list[int] = [] - mcp = FastMCP("guard") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def square(n: int) -> int: - calls.append(n) - return n * n - - async with running_task_server(mcp): - created = await submit_task(mcp, "square", {"n": 6}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": 36} - assert calls == [6] - - -def test_reentrant_wrapper_preserves_signature(): - """The wrapper keeps the tool's parameters so Docket DI is unchanged.""" - import inspect - - from fastmcp_tasks.input_loop import reentrant_task_fn - - async def fn(n: int, ctx: Any) -> int: - return n - - wrapped = reentrant_task_fn(fn, "fn") - assert list(inspect.signature(wrapped).parameters) == ["n", "ctx"] - - -async def test_state_only_guard_round_fails_clearly(): - """A state-only guard round (request_state, no input_requests) fails loudly. - - Foreground, the client re-invokes such a round after a backoff. The tasked - path has no self-continuation for it, so rather than silently completing with - a wrong result it surfaces an actionable error. - """ - mcp = FastMCP("guard-state-only") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def checkpoint(ctx: Context) -> str | mcp_types.InputRequiredResult: - if ctx.request_state is None: - return _input_required({}, request_state="carried") - return "done" - - async with running_task_server(mcp): - final = await wait_for_task( - mcp, (await submit_task(mcp, "checkpoint", {})).task_id - ) - - assert final.status == "completed" - assert final.result is not None - assert final.result["isError"] is True - assert "state-only" in final.result["content"][0]["text"] - - -async def test_partial_update_keeps_task_parked_on_remaining_request(): - """SEP-2663 partial fulfillment: a leg that asked two questions stays - `input_required` until both are answered, and each `tasks/get` in between - surfaces only what is still outstanding.""" - mcp = FastMCP("partial") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def two_questions(ctx: Context) -> str | mcp_types.InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _input_required( - { - "first": _elicit_request("First?"), - "second": _elicit_request("Second?"), - } - ) - return f"{_answer(responses, 'first')}+{_answer(responses, 'second')}" - - async with running_task_server(mcp): - created = await submit_task(mcp, "two_questions", {}) - parked = await wait_for_task( - mcp, created.task_id, target_states=frozenset({"input_required"}) - ) - assert parked.input_requests is not None - assert len(parked.input_requests) == 2 - - # Surfaced keys are freshly minted per request, so they carry no order - # a test can rely on. Identify each by the question it asks. - answered = _key_asking(parked.input_requests, "First?") - pending = _key_asking(parked.input_requests, "Second?") - await update_task( - mcp, - created.task_id, - {answered: {"action": "accept", "content": {"value": "one"}}}, - ) - - still_parked = await get_task(mcp, created.task_id) - assert still_parked.status == "input_required" - assert still_parked.input_requests is not None - assert list(still_parked.input_requests) == [pending] - - # Answering the last one resumes the leg, which now sees both answers. - await update_task( - mcp, - created.task_id, - {pending: {"action": "accept", "content": {"value": "two"}}}, - ) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["content"][0]["text"] == "one+two" - - -async def test_partial_update_waits_for_a_held_update_lock(): - """An update that arrives while another holds the lock must still land. - - SEP-2663 invites a client to answer a multi-request ask one key at a time, - so two updates can be in flight carrying *different* answers. Acknowledging - the one that loses the lock without storing its answer would leave the task - waiting forever on a key the client believes it already sent. - - The lock is taken out of band here so the contention is deterministic rather - than dependent on scheduling. - """ - mcp = FastMCP("lock-contention") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def two_questions(ctx: Context) -> str | mcp_types.InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _input_required( - { - "first": _elicit_request("First?"), - "second": _elicit_request("Second?"), - } - ) - return f"{_answer(responses, 'first')}+{_answer(responses, 'second')}" - - async with running_task_server(mcp): - created = await submit_task(mcp, "two_questions", {}) - parked = await wait_for_task( - mcp, created.task_id, target_states=frozenset({"input_required"}) - ) - assert parked.input_requests is not None - first = _key_asking(parked.input_requests, "First?") - second = _key_asking(parked.input_requests, "Second?") - - docket = mcp._docket - assert docket is not None - scope = get_task_scope() - - # Simulate a concurrent update in progress. - assert await acquire_update_lock(docket, scope, created.task_id) - pending = asyncio.create_task( - update_task( - mcp, - created.task_id, - {first: {"action": "accept", "content": {"value": "one"}}}, - ) - ) - await asyncio.sleep(0.05) - assert not pending.done(), "update returned while the lock was held" - await release_update_lock(docket, scope, created.task_id) - await pending - - # The blocked answer landed, so only the other key remains outstanding. - still_parked = await get_task(mcp, created.task_id) - assert still_parked.status == "input_required" - assert still_parked.input_requests is not None - assert list(still_parked.input_requests) == [second] - - await update_task( - mcp, - created.task_id, - {second: {"action": "accept", "content": {"value": "two"}}}, - ) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["content"][0]["text"] == "one+two" - - -async def test_final_answer_keeps_task_parked_until_next_leg_is_durable(): - """The last answer must not retire its outstanding marker early. - - Outstanding requests are what make a completed-but-parked leg read as - `input_required`. Discarding the final one before the next leg is enqueued - would let a `tasks/get` landing in that window see a finished execution with - no result and report the task complete. - """ - mcp = FastMCP("durable-reentry") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def one_question(ctx: Context) -> str | mcp_types.InputRequiredResult: - responses = ctx.input_responses - if responses is None: - return _input_required({"only": _elicit_request("Only?")}) - return f"got {_answer(responses, 'only')}" - - async with running_task_server(mcp): - created = await submit_task(mcp, "one_question", {}) - key = await _park_key(mcp, created.task_id) - - await update_task( - mcp, - created.task_id, - {key: {"action": "accept", "content": {"value": "answer"}}}, - ) - final = await wait_for_task(mcp, created.task_id) - - # The task must land on the real result, never on a phantom completion. - assert final.status == "completed" - assert final.result is not None - assert final.result["content"][0]["text"] == "got answer" - - -async def test_protocol_error_fails_the_task_with_inlined_error(): - """SEP-2663 reserves `failed` for protocol faults: an `MCPError` raised by - the body is inlined as a JSON-RPC error rather than reported as a completed - task carrying an `isError` result (which is what a `ToolError` produces).""" - mcp = FastMCP("protocol-fault") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def explodes() -> str: - raise MCPError(code=INTERNAL_ERROR, message="protocol fault", data={"x": 1}) - - async with running_task_server(mcp): - created = await submit_task(mcp, "explodes", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "failed" - assert final.result is None - assert final.error is not None - assert final.error["code"] == INTERNAL_ERROR - assert final.error["message"] == "protocol fault" - assert final.error["data"] == {"x": 1} diff --git a/tests/tasks/server/test_progress_dependency.py b/tests/tasks/server/test_progress_dependency.py deleted file mode 100644 index d948ed251..000000000 --- a/tests/tasks/server/test_progress_dependency.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Tests for FastMCP Progress dependency (SEP-2663 tasks).""" - -import asyncio -import json - -from mcp_types import TextContent - -from fastmcp import FastMCP -from fastmcp.server.dependencies import Progress -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - call_tool_without_optin, - running_task_server, - submit_task, - wait_for_task, -) - - -async def test_progress_in_immediate_execution(): - """Progress dependency works when a tool runs synchronously.""" - mcp = FastMCP("test") - - @mcp.tool - async def test_tool(progress: Progress = Progress()) -> str: - await progress.set_total(10) - await progress.increment() - await progress.set_message("Testing") - return "done" - - result = await call_tool_without_optin(mcp, "test_tool", {}) - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "done" - - -async def test_progress_in_background_task(): - """Progress dependency works inside a background task.""" - mcp = FastMCP("test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def test_task(progress: Progress = Progress()) -> str: - await progress.set_total(5) - await progress.increment() - await progress.set_message("Step 1") - return "done" - - async with running_task_server(mcp): - created = await submit_task(mcp, "test_task", {}) - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "done"} - - -async def test_progress_tracks_multiple_increments(): - """Progress correctly tracks multiple increment calls.""" - mcp = FastMCP("test") - - @mcp.tool - async def count_to_ten(progress: Progress = Progress()) -> str: - await progress.set_total(10) - for _ in range(10): - await progress.increment() - return "counted" - - result = await call_tool_without_optin(mcp, "count_to_ten", {}) - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "counted" - - -async def test_progress_status_message_in_background_task(): - """A working task surfaces the current progress message as statusMessage.""" - mcp = FastMCP("test") - mcp.add_extension(TasksExtension()) - release = asyncio.Event() - - @mcp.tool(task=True) - async def task_with_progress(progress: Progress = Progress()) -> str: - await progress.set_total(3) - await progress.set_message("Step 1 of 3") - await progress.increment() - await release.wait() - await progress.set_message("Step 2 of 3") - await progress.increment() - return "done" - - async with running_task_server(mcp): - created = await submit_task(mcp, "task_with_progress", {}) - - # The task parks on `release` while working; its statusMessage should - # reflect the progress message (or be None, depending on the poll race). - working = await wait_for_task( - mcp, created.task_id, target_states=frozenset({"working"}) - ) - msg = working.status_message - assert msg is None or msg.startswith("Step") - - release.set() - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "done"} - - -async def test_inmemory_progress_state(): - """In-memory progress stores and returns state correctly.""" - mcp = FastMCP("test") - - @mcp.tool - async def test_tool(progress: Progress = Progress()) -> dict: - assert progress.current is None - assert progress.total == 1 - assert progress.message is None - - await progress.set_total(10) - assert progress.total == 10 - - await progress.increment() - assert progress.current == 1 - - await progress.increment(2) - assert progress.current == 3 - - await progress.set_message("Testing") - assert progress.message == "Testing" - - return { - "current": progress.current, - "total": progress.total, - "message": progress.message, - } - - result = await call_tool_without_optin(mcp, "test_tool", {}) - assert isinstance(result.content[0], TextContent) - state = json.loads(result.content[0].text) - assert state["current"] == 3 - assert state["total"] == 10 - assert state["message"] == "Testing" diff --git a/tests/tasks/server/test_reenter_shutdown.py b/tests/tasks/server/test_reenter_shutdown.py deleted file mode 100644 index 54544839e..000000000 --- a/tests/tasks/server/test_reenter_shutdown.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Shutdown regression for end-and-reenter task input. - -The whole point of end-and-reenter is that a task waiting on client input holds -no worker: the guard leg's Docket execution completed and the worker is free. -This test proves it — a task parked in ``input_required`` that is never answered -must not delay server shutdown. Under the old block-and-resume model the worker -sat on a Redis wait for the input TTL and wedged teardown; here the lifespan -exits promptly. -""" - -from __future__ import annotations - -import asyncio - -import mcp_types - -from fastmcp import Context, FastMCP -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import running_task_server, submit_task, wait_for_task - - -def _elicit_request(message: str) -> mcp_types.ElicitRequest: - return mcp_types.ElicitRequest( - params=mcp_types.ElicitRequestFormParams( - message=message, - requested_schema={ - "type": "object", - "properties": {"value": {"type": "string"}}, - }, - ) - ) - - -async def test_parked_task_does_not_delay_shutdown(): - """Exiting the lifespan with a task in input_required (never answered) must - return promptly — no worker is parked awaiting input.""" - mcp = FastMCP("parked-shutdown") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def greet(ctx: Context) -> str | mcp_types.InputRequiredResult: - if ctx.input_responses is None: - return mcp_types.InputRequiredResult( - result_type="input_required", - input_requests={"name": _elicit_request("Your name?")}, - request_state=None, - ) - return "done" - - loop = asyncio.get_event_loop() - manager = running_task_server(mcp) - await manager.__aenter__() - try: - created = await submit_task(mcp, "greet", {}) - parked = await wait_for_task( - mcp, created.task_id, target_states=frozenset({"input_required"}) - ) - assert parked.status == "input_required" - finally: - # Never answer; time how long teardown takes. - started = loop.time() - await manager.__aexit__(None, None, None) - elapsed = loop.time() - started - - assert elapsed < 3.0, f"lifespan took {elapsed:.2f}s to exit with a parked task" diff --git a/tests/tasks/server/test_server_tasks_parameter.py b/tests/tasks/server/test_server_tasks_parameter.py deleted file mode 100644 index e815e79c0..000000000 --- a/tests/tasks/server/test_server_tasks_parameter.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Server-level `tasks` default inheritance and per-tool override (tools only). - -`FastMCP(tasks=...)` sets the default task mode for tools; a per-tool `task=` -overrides it. SEP-2663 tasks are tools-only, so prompt/resource/template -inheritance is not covered. Tasking is driven in-process through the interceptor. -""" - -from __future__ import annotations - -from fastmcp_tasks.models import CreateTaskResult - -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - _opted_in_request, - auth_scope, - run_task, - running_task_server, - submit_task, -) - - -async def _opted_in_call(server: FastMCP, name: str, arguments: dict | None = None): - """Run a `tools/call` WITH the tasks opt-in bound (used to prove sync paths).""" - with auth_scope(None), _opted_in_request(name, arguments or {}, None): - return await server.call_tool(name, arguments or {}) - - -async def test_tool_inherits_server_default_true(): - """A tool inherits the server's tasks=True default and tasks when opted in.""" - mcp = FastMCP("test", tasks=True) - mcp.add_extension(TasksExtension()) - - @mcp.tool - async def my_tool() -> str: - return "tool result" - - async with running_task_server(mcp): - created = await submit_task(mcp, "my_tool") - assert isinstance(created, CreateTaskResult) - - -async def test_tool_inherits_server_default_false(): - """A tool inherits the server's tasks=False default and runs synchronously.""" - mcp = FastMCP("test", tasks=False) - - @mcp.tool - async def my_tool() -> str: - return "tool result" - - result = await _opted_in_call(mcp, "my_tool") - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "tool result"} - - -async def test_server_tasks_none_defaults_to_forbidden(): - """A server with tasks omitted defaults tools to forbidden (runs sync).""" - mcp = FastMCP("test") # tasks omitted -> forbidden default - - @mcp.tool - async def my_tool() -> str: - return "tool result" - - result = await _opted_in_call(mcp, "my_tool") - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "tool result"} - - -async def test_per_tool_true_overrides_server_false(): - """A per-tool task=True overrides the server default of tasks=False.""" - mcp = FastMCP("test", tasks=False) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def task_tool() -> str: - return "background result" - - @mcp.tool - async def default_tool() -> str: - return "immediate result" - - async with running_task_server(mcp): - created = await submit_task(mcp, "task_tool") - assert isinstance(created, CreateTaskResult) - - # The inherited-forbidden tool still runs synchronously despite the opt-in. - result = await _opted_in_call(mcp, "default_tool") - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "immediate result"} - - -async def test_per_tool_false_overrides_server_true(): - """A per-tool task=False overrides the server default of tasks=True.""" - mcp = FastMCP("test", tasks=True) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=False) - async def no_task_tool() -> str: - return "immediate result" - - @mcp.tool - async def default_tool() -> str: - return "background result" - - async with running_task_server(mcp): - # Explicit False runs synchronously even when opted in. - result = await _opted_in_call(mcp, "no_task_tool") - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "immediate result"} - - # The inherited-optional tool tasks when opted in. - created = await submit_task(mcp, "default_tool") - assert isinstance(created, CreateTaskResult) - - -async def test_task_with_custom_tool_name(): - """Tools registered under a custom name task correctly (issue #2642). - - When a tool is registered with a custom name different from the function - name, task execution uses the custom name for Docket lookup. - """ - mcp = FastMCP("test", tasks=True) - mcp.add_extension(TasksExtension()) - - async def my_function() -> str: - return "result from custom-named tool" - - mcp.tool(my_function, name="custom-tool-name") - - async with running_task_server(mcp): - final = await run_task(mcp, "custom-tool-name") - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == { - "result": "result from custom-named tool" - } diff --git a/tests/tasks/server/test_snapshot_encryption.py b/tests/tasks/server/test_snapshot_encryption.py deleted file mode 100644 index 0c35b6fe4..000000000 --- a/tests/tasks/server/test_snapshot_encryption.py +++ /dev/null @@ -1,437 +0,0 @@ -"""Tests for encryption of the task-context snapshot at rest (#4747). - -The snapshot carries the submitting caller's access token and every inbound HTTP -header, and it is written to the Docket backend for the task's TTL. With a -distributed backend those credentials sit in Redis where the backend's operators -can read them. Setting ``FASTMCP_TASKS_ENCRYPTION_KEY`` makes the snapshot a Fernet -token instead, and makes a worker that cannot decrypt one fail the task rather -than run it as an anonymous caller. -""" - -from __future__ import annotations - -import json -import logging -from collections.abc import Iterator -from unittest.mock import patch - -import pytest -from fastmcp_tasks.context import TaskContextSnapshot -from fastmcp_tasks.encryption import ( - EncryptedCodec, - PlaintextCodec, - SnapshotDecryptionError, - clear_codec_cache, - snapshot_codec, -) -from fastmcp_tasks.keys import task_redis_prefix -from fastmcp_tasks.settings import TasksSettings, tasks_settings -from pydantic import SecretStr - -from fastmcp import FastMCP -from fastmcp.server.dependencies import get_access_token -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - get_task, - make_access_token, - running_task_server, - submit_task, - wait_for_task, -) - -KEY = "a-test-encryption-key-for-snapshots" -OTHER_KEY = "a-different-test-encryption-key-entirely" - - -@pytest.fixture -def encryption_key() -> Iterator[str]: - """Configure the tasks encryption key for the duration of a test.""" - clear_codec_cache() - previous = tasks_settings.encryption_key - tasks_settings.encryption_key = SecretStr(KEY) - try: - yield KEY - finally: - tasks_settings.encryption_key = previous - clear_codec_cache() - - -@pytest.fixture -def no_encryption_key() -> Iterator[None]: - """Guarantee no key is configured, whatever the ambient environment holds.""" - clear_codec_cache() - previous = tasks_settings.encryption_key - tasks_settings.encryption_key = None - try: - yield - finally: - tasks_settings.encryption_key = previous - clear_codec_cache() - - -@pytest.fixture -def sensitive_snapshot() -> TaskContextSnapshot: - """A snapshot carrying a bearer token and an Authorization header.""" - token = make_access_token("client-a", "user-1") - return TaskContextSnapshot( - access_token_json=token.model_dump_json(), - http_headers={"authorization": f"Bearer {token.token}", "x-trace-id": "abc"}, - origin_request_id="req-1", - session_id="session-1", - owning_tool_name="peek", - owning_tool_version="1.0", - ) - - -class TestSnapshotCodec: - def test_round_trips_a_payload(self): - codec = EncryptedCodec(KEY) - assert codec.decode(codec.encode('{"a": 1}')) == '{"a": 1}' - - def test_encoded_payload_hides_the_credentials( - self, sensitive_snapshot: TaskContextSnapshot - ): - encoded = EncryptedCodec(KEY).encode(sensitive_snapshot.to_json()) - assert "token-client-a-user-1" not in encoded - assert "authorization" not in encoded - - def test_decode_rejects_another_keys_payload(self): - encoded = EncryptedCodec(OTHER_KEY).encode('{"a": 1}') - with pytest.raises(SnapshotDecryptionError): - EncryptedCodec(KEY).decode(encoded) - - def test_decode_rejects_plaintext(self): - """A snapshot written before the key was set must not be trusted.""" - with pytest.raises(SnapshotDecryptionError): - EncryptedCodec(KEY).decode('{"access_token_json": null}') - - def test_empty_material_is_rejected(self): - """An empty key would derive a universally reproducible Fernet key.""" - with pytest.raises(ValueError, match="must not be empty"): - EncryptedCodec("") - - def test_decode_accepts_bytes(self): - """Redis hands back bytes on some backends.""" - codec = EncryptedCodec(KEY) - assert codec.decode(codec.encode('{"a": 1}').encode()) == '{"a": 1}' - - def test_same_key_reuses_one_codec(self, encryption_key: str): - assert snapshot_codec() is snapshot_codec() - - def test_plaintext_codec_without_a_key(self, no_encryption_key: None): - codec = snapshot_codec() - assert isinstance(codec, PlaintextCodec) - assert not codec.protected - - def test_plaintext_codec_is_a_pass_through(self): - codec = PlaintextCodec() - assert codec.encode('{"a": 1}') == '{"a": 1}' - assert codec.decode('{"a": 1}') == '{"a": 1}' - assert codec.decode(b'{"a": 1}') == '{"a": 1}' - - def test_plaintext_codec_refuses_an_encrypted_payload(self): - """A keyless process must not pass ciphertext through as plaintext. - - Passing it through would end in a swallowed parse error and an - anonymous run, defeating the submitter's fail-closed configuration. - """ - encrypted = EncryptedCodec(KEY).encode('{"a": 1}') - with pytest.raises( - SnapshotDecryptionError, match="no FASTMCP_TASKS_ENCRYPTION_KEY" - ): - PlaintextCodec().decode(encrypted) - - -class TestTasksSettings: - def test_encryption_key_defaults_to_none(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("FASTMCP_TASKS_ENCRYPTION_KEY", raising=False) - - assert TasksSettings().encryption_key is None - - def test_encryption_key_env_var(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("FASTMCP_TASKS_ENCRYPTION_KEY", "s3kr1t-material") - - key = TasksSettings().encryption_key - assert key is not None - assert key.get_secret_value() == "s3kr1t-material" - - def test_encryption_key_is_not_printable(self, monkeypatch: pytest.MonkeyPatch): - """A settings dump must never carry the key into a log.""" - monkeypatch.setenv("FASTMCP_TASKS_ENCRYPTION_KEY", "s3kr1t-material") - - assert "s3kr1t-material" not in repr(TasksSettings()) - - -class TestSnapshotSerialization: - def test_json_round_trip_preserves_every_field( - self, sensitive_snapshot: TaskContextSnapshot - ): - assert ( - TaskContextSnapshot.from_json(sensitive_snapshot.to_json()) - == sensitive_snapshot - ) - - -async def _read_stored_snapshot(mcp: FastMCP, task_scope: str, task_id: str) -> str: - """Return the raw stored value of a task's snapshot key.""" - docket = mcp._docket - assert docket is not None - key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot") - async with docket.redis() as redis: - raw = await redis.get(key) - assert raw is not None - return raw.decode() if isinstance(raw, bytes) else str(raw) - - -async def _write_stored_snapshot( - mcp: FastMCP, task_scope: str, task_id: str, payload: str -) -> None: - """Overwrite a task's stored snapshot value.""" - docket = mcp._docket - assert docket is not None - key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot") - async with docket.redis() as redis: - await redis.set(key, payload) - - -async def _delete_stored_snapshot(mcp: FastMCP, task_scope: str, task_id: str) -> None: - """Remove a task's stored snapshot, as a TTL expiry would.""" - docket = mcp._docket - assert docket is not None - key = docket.key(f"{task_redis_prefix(task_scope)}:{task_id}:snapshot") - async with docket.redis() as redis: - await redis.delete(key) - - -@pytest.fixture -def echo_token_server() -> FastMCP: - """A task server whose one tool reports the caller it restored.""" - mcp = FastMCP("snapshot-encryption-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def whoami() -> str: - token = get_access_token() - return token.token if token else "no-token" - - return mcp - - -class TestEncryptedSnapshotRoundTrip: - async def test_worker_still_sees_the_submitting_caller( - self, echo_token_server: FastMCP, encryption_key: str - ): - token = make_access_token("client-a", "user-1") - - async with running_task_server(echo_token_server): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - final = await wait_for_task( - echo_token_server, created.task_id, access_token=token - ) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": token.token} - - async def test_stored_value_is_not_readable( - self, echo_token_server: FastMCP, encryption_key: str - ): - token = make_access_token("client-a", "user-1") - - async with running_task_server(echo_token_server): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - stored = await _read_stored_snapshot( - echo_token_server, "client-a|user-1", created.task_id - ) - await wait_for_task(echo_token_server, created.task_id, access_token=token) - - assert token.token not in stored - assert "authorization" not in stored - with pytest.raises(json.JSONDecodeError): - json.loads(stored) - - async def test_undecryptable_snapshot_fails_the_task( - self, - echo_token_server: FastMCP, - encryption_key: str, - caplog: pytest.LogCaptureFixture, - ): - """Fail closed: a worker that cannot recover the caller must not run. - - Running anyway would execute the tool as an anonymous caller, which for - an authorization-sensitive tool is worse than not running at all. Docket - surfaces this on the wire as a generic dependency failure, so the named - cause has to come from the log. - """ - token = make_access_token("client-a", "user-1") - tampered = EncryptedCodec(OTHER_KEY).encode(TaskContextSnapshot().to_json()) - - with caplog.at_level(logging.ERROR, logger="fastmcp_tasks.context"): - async with running_task_server(echo_token_server): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - await _write_stored_snapshot( - echo_token_server, "client-a|user-1", created.task_id, tampered - ) - final = await wait_for_task( - echo_token_server, - created.task_id, - access_token=token, - target_states=frozenset({"failed"}), - ) - - assert final.status == "failed" - assert final.error is not None - assert "FASTMCP_TASKS_ENCRYPTION_KEY" in caplog.text - - async def test_missing_snapshot_fails_the_task( - self, echo_token_server: FastMCP, encryption_key: str - ): - """Fail closed extends to a snapshot that is gone, not just unreadable. - - A missing snapshot is reachable in production through TTL expiry, and - it loses the caller just as completely as a wrong key does. - """ - token = make_access_token("client-a", "user-1") - - async with running_task_server(echo_token_server): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - await _delete_stored_snapshot( - echo_token_server, "client-a|user-1", created.task_id - ) - final = await wait_for_task( - echo_token_server, - created.task_id, - access_token=token, - target_states=frozenset({"failed"}), - ) - - assert final.status == "failed" - - async def test_unparseable_snapshot_fails_the_task( - self, echo_token_server: FastMCP, encryption_key: str - ): - """Fail closed extends past decryption: a parse failure also loses the - caller, so it must not degrade to an anonymous run.""" - token = make_access_token("client-a", "user-1") - - def boom(*_args, **_kwargs): - raise RuntimeError("simulated deserialization failure") - - async with running_task_server(echo_token_server): - with patch.object(TaskContextSnapshot, "from_json", boom): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - final = await wait_for_task( - echo_token_server, - created.task_id, - access_token=token, - target_states=frozenset({"failed"}), - ) - - assert final.status == "failed" - - async def test_keyless_worker_fails_the_encrypted_task( - self, echo_token_server: FastMCP, encryption_key: str - ): - """A worker whose key was lost mid-rollout must not run anonymously. - - The submitter wrote an encrypted snapshot; the restoring process has no - key at all, so its plaintext codec would otherwise pass the ciphertext - through to a parse failure the fail-open path swallows. - """ - token = make_access_token("client-a", "user-1") - - async with running_task_server(echo_token_server): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - tasks_settings.encryption_key = None - clear_codec_cache() - final = await wait_for_task( - echo_token_server, - created.task_id, - access_token=token, - target_states=frozenset({"failed"}), - ) - - assert final.status == "failed" - - -class TestUnencryptedByDefault: - async def test_snapshot_stays_plaintext_without_a_key( - self, echo_token_server: FastMCP, no_encryption_key: None - ): - """No key configured is the pre-existing contract, unchanged.""" - token = make_access_token("client-a", "user-1") - - async with running_task_server(echo_token_server): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - stored = await _read_stored_snapshot( - echo_token_server, "client-a|user-1", created.task_id - ) - final = await wait_for_task( - echo_token_server, created.task_id, access_token=token - ) - - assert json.loads(stored)["access_token_json"] is not None - assert final.status == "completed" - - async def test_unreadable_snapshot_is_nonfatal_without_a_key( - self, echo_token_server: FastMCP, no_encryption_key: None - ): - """Without encryption a corrupt snapshot still only degrades the caller.""" - token = make_access_token("client-a", "user-1") - - async with running_task_server(echo_token_server): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - await _write_stored_snapshot( - echo_token_server, "client-a|user-1", created.task_id, "not json" - ) - final = await wait_for_task( - echo_token_server, created.task_id, access_token=token - ) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "no-token"} - - -class TestTaskStillResolvesAfterFailure: - async def test_failed_task_reports_an_error( - self, echo_token_server: FastMCP, encryption_key: str - ): - """A fail-closed task is still a well-formed `tasks/get` result.""" - token = make_access_token("client-a", "user-1") - tampered = EncryptedCodec(OTHER_KEY).encode(TaskContextSnapshot().to_json()) - - async with running_task_server(echo_token_server): - created = await submit_task( - echo_token_server, "whoami", {}, access_token=token - ) - await _write_stored_snapshot( - echo_token_server, "client-a|user-1", created.task_id, tampered - ) - await wait_for_task( - echo_token_server, - created.task_id, - access_token=token, - target_states=frozenset({"failed"}), - ) - fetched = await get_task( - echo_token_server, created.task_id, access_token=token - ) - - assert fetched.status == "failed" diff --git a/tests/tasks/server/test_snapshot_restore.py b/tests/tasks/server/test_snapshot_restore.py deleted file mode 100644 index aeb3e97d9..000000000 --- a/tests/tasks/server/test_snapshot_restore.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Tests for ``restore_task_snapshot`` — the worker-level Docket dependency -that restores the task-context snapshot into the ``_task_snapshot`` -ContextVar before each task runs. - -With the snapshot restored up front, sync helpers (``get_access_token``, -``get_http_request``, etc.) never need to hit Redis themselves. These -tests exercise the restore path end-to-end (via in-memory Docket) and -the edge cases around non-fastmcp keys and failed restores. -""" - -from __future__ import annotations - -import contextvars -from unittest.mock import patch - -import pytest -from fastmcp_tasks.context import ( - TaskContextSnapshot, - _apply_snapshot_to_context, - _recall_snapshot, - get_task_context, - restore_task_snapshot, -) -from mcp.server.auth.middleware.auth_context import auth_context_var -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser - -from fastmcp import FastMCP -from fastmcp.server.auth import AccessToken -from fastmcp.server.dependencies import get_access_token, get_http_headers -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - running_task_server, - submit_task, - wait_for_task, -) - - -async def test_snapshot_restored_before_user_code_runs(): - """A tool with no declared deps finds the snapshot already cached.""" - mcp = FastMCP("snapshot-restore-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def bare_tool() -> bool: - info = get_task_context() - assert info is not None - return _recall_snapshot(info.task_id) is not None - - async with running_task_server(mcp): - created = await submit_task(mcp, "bare_tool", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": True} - - -async def test_get_access_token_in_bg_task_without_context_dep(): - """Issue #3897 repro: get_access_token() works in a bg task that does - not declare Context as a dependency.""" - mcp = FastMCP("access-token-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def bare_tool() -> str: - token = get_access_token() - return token.token if token else "no-token" - - test_token = AccessToken( - token="jwt-3897", - client_id="test-client", - scopes=["read"], - claims={"sub": "user-x"}, - ) - auth_context_var.set(AuthenticatedUser(test_token)) - - async with running_task_server(mcp): - created = await submit_task(mcp, "bare_tool", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "jwt-3897"} - - -def test_apply_snapshot_restores_auth_and_headers_in_clean_context(): - """The cross-process path: with nothing inherited, the snapshot alone makes - get_access_token()/get_http_headers() see the submitting caller. - - A Redis-backed worker runs in a separate process and inherits none of the - submitter's context vars, so contextvar inheritance (which carries the token - on the same-process memory:// path) cannot help. Running in a fresh - `copy_context()` with no auth/request bound simulates that worker: only - `_apply_snapshot_to_context` populating the ambient vars makes the token and - headers reachable. - """ - token = AccessToken( - token="jwt-remote", - client_id="remote-client", - scopes=["read"], - claims={"sub": "user-y"}, - ) - snapshot = TaskContextSnapshot( - access_token_json=token.model_dump_json(), - http_headers={"x-trace-id": "abc123"}, - ) - - def run_in_clean_worker_context() -> None: - # Nothing bound here — no inheritance to fall back on. - assert get_access_token() is None - assert get_http_headers() == {} - _apply_snapshot_to_context(snapshot) - restored = get_access_token() - assert restored is not None - assert restored.token == "jwt-remote" - assert restored.client_id == "remote-client" - assert get_http_headers()["x-trace-id"] == "abc123" - - contextvars.copy_context().run(run_in_clean_worker_context) - - -def test_apply_snapshot_headers_without_faking_a_request(): - """Snapshot headers are readable, but no live request is fabricated. - - `get_http_headers()` returns the submitting request's headers, while - `get_http_request()` still raises — there is no live request inside a - background task, and impersonating one would make `CurrentRequest()` expose - invented method/URL/client data. - """ - from fastmcp.server.dependencies import get_http_request - - snapshot = TaskContextSnapshot(http_headers={"x-trace-id": "abc123"}) - - def run_in_clean_worker_context() -> None: - _apply_snapshot_to_context(snapshot) - assert get_http_headers()["x-trace-id"] == "abc123" - with pytest.raises(RuntimeError): - get_http_request() - - contextvars.copy_context().run(run_in_clean_worker_context) - - -def test_apply_snapshot_skips_expired_token(): - """An expired snapshot token is not installed, so the worker is unauthenticated. - - A task may sit queued past its submitter's token expiry. A live request with - an expired bearer token is rejected (401), so restoring one as authenticated - would let a delayed task run under credentials that should now be treated as - unauthenticated. The headers still restore — only the auth token is dropped. - """ - expired = AccessToken( - token="jwt-expired", - client_id="remote-client", - scopes=["read"], - expires_at=1, # 1970 — long past - ) - snapshot = TaskContextSnapshot( - access_token_json=expired.model_dump_json(), - http_headers={"x-trace-id": "abc123"}, - ) - - def run_in_clean_worker_context() -> None: - assert get_access_token() is None - _apply_snapshot_to_context(snapshot) - assert get_access_token() is None - # Non-auth context still restores independently of the token. - assert get_http_headers()["x-trace-id"] == "abc123" - - contextvars.copy_context().run(run_in_clean_worker_context) - - -def test_apply_snapshot_clears_prior_auth_in_reused_context(): - """An anonymous task must not inherit a prior task's identity or headers. - - A Docket worker may reuse an asyncio context across executions. Applying a - tokenless snapshot after an authenticated one must clear the earlier - caller's `auth_context_var` and headers rather than leave them installed. - """ - prior = AccessToken(token="jwt-prior", client_id="prior-client", scopes=["read"]) - authed = TaskContextSnapshot( - access_token_json=prior.model_dump_json(), - http_headers={"x-trace-id": "prior"}, - ) - anonymous = TaskContextSnapshot() - - def run_in_reused_worker_context() -> None: - _apply_snapshot_to_context(authed) - assert get_access_token() is not None - assert get_http_headers()["x-trace-id"] == "prior" - - # Same context, next task carries no auth/headers. - _apply_snapshot_to_context(anonymous) - assert get_access_token() is None - assert get_http_headers() == {} - - contextvars.copy_context().run(run_in_reused_worker_context) - - -async def test_restore_failure_is_nonfatal(): - """If deserialization blows up, the task still runs to completion and - the snapshot cache stays empty.""" - mcp = FastMCP("restore-failure-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def bare_tool() -> bool: - info = get_task_context() - assert info is not None - return _recall_snapshot(info.task_id) is not None - - def boom(*_args, **_kwargs): - raise RuntimeError("simulated deserialization failure") - - async with running_task_server(mcp): - with patch.object(TaskContextSnapshot, "from_json", boom): - created = await submit_task(mcp, "bare_tool", {}) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": False} - - -async def test_restore_skipped_for_non_fastmcp_task_keys(): - """The restore dep returns cleanly for keys it doesn't recognize and - writes nothing to the snapshot cache.""" - # Direct calls bypass the worker, so Redis/Docket never gets involved - # — any attempt to touch them would raise. - await restore_task_snapshot(key="not-a-fastmcp-key") - await restore_task_snapshot(key="weird:client-a:task-1:tool:my_tool") - await restore_task_snapshot(key="") diff --git a/tests/tasks/server/test_sync_function_task_disabled.py b/tests/tasks/server/test_sync_function_task_disabled.py deleted file mode 100644 index 5230477ec..000000000 --- a/tests/tasks/server/test_sync_function_task_disabled.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Tests that synchronous functions cannot be used as background tasks. - -SEP-2663 tasks are tools-only. Docket requires async functions for background -execution, so FastMCP raises ValueError when task=True is used with a sync tool -function. These are registration-time checks and need no running server. -""" - -import pytest - -from fastmcp import FastMCP -from fastmcp.tools.function_tool import FunctionTool - - -async def test_sync_tool_with_explicit_task_true_raises(): - """Sync tool with task=True raises ValueError.""" - mcp = FastMCP("test") - - with pytest.raises( - ValueError, match="uses a sync function but has task execution enabled" - ): - - @mcp.tool(task=True) - def sync_tool(x: int) -> int: - """A synchronous tool.""" - return x * 2 - - -async def test_sync_tool_with_inherited_task_true_raises(): - """Sync tool inheriting task=True from server raises ValueError.""" - mcp = FastMCP("test", tasks=True) - - with pytest.raises( - ValueError, match="uses a sync function but has task execution enabled" - ): - - @mcp.tool() # Inherits task=True from server - def sync_tool(x: int) -> int: - """A synchronous tool.""" - return x * 2 - - -async def test_async_tool_with_task_true_remains_enabled(): - """Async tools with task=True keep task support enabled.""" - mcp = FastMCP("test") - - @mcp.tool(task=True) - async def async_tool(x: int) -> int: - """An async tool.""" - return x * 2 - - tool = await mcp.get_tool("async_tool") - assert isinstance(tool, FunctionTool) - assert tool.task_config.mode == "optional" - - -async def test_sync_tool_with_task_false_works(): - """Sync tool with explicit task=False works (no error).""" - mcp = FastMCP("test", tasks=True) - - @mcp.tool(task=False) # Explicitly disable - def sync_tool(x: int) -> int: - """A synchronous tool.""" - return x * 2 - - tool = await mcp.get_tool("sync_tool") - assert isinstance(tool, FunctionTool) - assert tool.task_config.mode == "forbidden" - - -# ============================================================================= -# Callable classes with async __call__ -# ============================================================================= - - -async def test_async_callable_class_tool_with_task_true_works(): - """Callable class with async __call__ and task=True should work.""" - from fastmcp.tools import Tool - - class AsyncCallableTool: - async def __call__(self, x: int) -> int: - return x * 2 - - tool = Tool.from_function(AsyncCallableTool(), task=True) - assert tool.task_config.mode == "optional" - - -async def test_sync_callable_class_tool_with_task_true_raises(): - """Callable class with sync __call__ and task=True should raise.""" - from fastmcp.tools import Tool - - class SyncCallableTool: - def __call__(self, x: int) -> int: - return x * 2 - - with pytest.raises( - ValueError, match="uses a sync function but has task execution enabled" - ): - Tool.from_function(SyncCallableTool(), task=True) diff --git a/tests/tasks/server/test_task_capabilities.py b/tests/tasks/server/test_task_capabilities.py deleted file mode 100644 index 8467ccc42..000000000 --- a/tests/tasks/server/test_task_capabilities.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Advertisement of the SEP-2663 tasks extension capability. - -A server with the tasks extension registered advertises the -`io.modelcontextprotocol/tasks` extension in its capabilities; a server without -it does not. -""" - -from __future__ import annotations - -from fastmcp import FastMCP -from fastmcp.client import Client -from fastmcp.utilities.tasks import TASKS_EXTENSION_ID -from fastmcp_tasks import TasksExtension - - -async def test_extension_capability_advertised(): - """The tasks extension is advertised when registered.""" - mcp = FastMCP("capability-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def my_tool() -> str: - return "ok" - - async with Client(mcp, mode="auto") as client: - extensions = client.server_capabilities.extensions or {} - assert extensions.get(TASKS_EXTENSION_ID) == {} - - -async def test_extension_capability_absent_without_extension(): - """The tasks extension is not advertised when no extension is registered.""" - mcp = FastMCP("capability-test") - - @mcp.tool - async def my_tool() -> str: - return "ok" - - async with Client(mcp, mode="auto") as client: - extensions = client.server_capabilities.extensions or {} - assert TASKS_EXTENSION_ID not in extensions diff --git a/tests/tasks/server/test_task_config.py b/tests/tasks/server/test_task_config.py deleted file mode 100644 index f839debff..000000000 --- a/tests/tasks/server/test_task_config.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Tests for TaskConfig (SEP-2663, tools only). - -Tests for TaskConfig: -- Normalization of boolean task values to TaskConfig -- Sync-function validation -- Tool mode enforcement (forbidden, optional, required) -- Tool execution metadata (task_support in tools/list) -- Poll interval configuration -""" - -from datetime import timedelta - -import pytest -from fastmcp_tasks.models import ( - MISSING_REQUIRED_CLIENT_CAPABILITY, - CreateTaskResult, -) -from mcp.shared.exceptions import MCPError -from mcp_types import ToolExecution - -from fastmcp import FastMCP -from fastmcp.tools.base import Tool -from fastmcp.utilities.tasks import TaskConfig -from fastmcp.utilities.versions import VersionSpec -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - _opted_in_request, - auth_scope, - call_tool_without_optin, - running_task_server, - submit_task, -) - - -async def _opted_in_call(server: FastMCP, name: str, arguments: dict | None = None): - """Run a `tools/call` WITH the tasks opt-in bound (used to prove sync paths).""" - with auth_scope(None), _opted_in_request(name, arguments or {}, None): - return await server.call_tool(name, arguments or {}) - - -def test_docket_settings_load_from_dotenv(tmp_path, monkeypatch): - """`FASTMCP_DOCKET_*` in a `.env` file configures the backend. - - A distributed deployment that puts its Redis URL in `.env` must not silently - fall back to `memory://` — DocketSettings loads the same dotenv source as - core FastMCP settings. - """ - from fastmcp_tasks.settings import DocketSettings - - (tmp_path / ".env").write_text("FASTMCP_DOCKET_URL=redis://dotenv-host:6379/2\n") - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("FASTMCP_DOCKET_URL", raising=False) - - assert DocketSettings().url == "redis://dotenv-host:6379/2" - - -async def test_interceptor_tasks_the_requested_version_not_the_highest(): - """A versioned tools/call tasks the version the caller asked for. - - Two versions share a name but differ in task mode: v1 is task-forbidden, - v2 is task-optional. A call targeting v1 (with the tasks opt-in) must run v1 - synchronously — resolving the highest version instead would wrongly task v2. - """ - mcp = FastMCP("versioned-tasks") - mcp.add_extension(TasksExtension()) - - @mcp.tool(name="calc", version="1.0") - async def calc_v1() -> str: - return "v1-sync" - - @mcp.tool(name="calc", version="2.0", task=True) - async def calc_v2() -> str: - return "v2" - - async with running_task_server(mcp): - with auth_scope(None), _opted_in_request("calc", {}, None): - result = await mcp.call_tool("calc", {}, version=VersionSpec(eq="1.0")) - - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "v1-sync"} - - -class TestTaskConfigNormalization: - """Test that boolean task values normalize correctly to TaskConfig.""" - - async def test_task_true_normalizes_to_optional(self): - """task=True should normalize to TaskConfig(mode='optional').""" - mcp = FastMCP("test", tasks=False) # Disable default task support - - @mcp.tool(task=True) - async def my_tool() -> str: - return "ok" - - tool = await mcp.get_tool("my_tool") - assert isinstance(tool, Tool) - assert tool.task_config.mode == "optional" - - async def test_task_false_normalizes_to_forbidden(self): - """task=False should normalize to TaskConfig(mode='forbidden').""" - mcp = FastMCP("test", tasks=False) - - @mcp.tool(task=False) - async def my_tool() -> str: - return "ok" - - tool = await mcp.get_tool("my_tool") - assert isinstance(tool, Tool) - assert tool.task_config.mode == "forbidden" - - async def test_task_config_passed_directly(self): - """TaskConfig should be preserved when passed directly.""" - mcp = FastMCP("test", tasks=False) - - @mcp.tool(task=TaskConfig(mode="required")) - async def my_tool() -> str: - return "ok" - - tool = await mcp.get_tool("my_tool") - assert isinstance(tool, Tool) - assert tool.task_config.mode == "required" - - async def test_default_task_inherits_server_default(self): - """Default task value should inherit from server default.""" - # Server with tasks disabled - mcp_no_tasks = FastMCP("test", tasks=False) - - @mcp_no_tasks.tool() - def my_tool_sync() -> str: - return "ok" - - tool = await mcp_no_tasks.get_tool("my_tool_sync") - assert isinstance(tool, Tool) - assert tool.task_config.mode == "forbidden" - - # Server with tasks enabled - mcp_tasks = FastMCP("test", tasks=True) - - @mcp_tasks.tool() - async def my_tool_async() -> str: - return "ok" - - tool2 = await mcp_tasks.get_tool("my_tool_async") - assert isinstance(tool2, Tool) - assert tool2.task_config.mode == "optional" - - -class TestToolModeEnforcement: - """Test mode enforcement for tools under the SEP-2663 interceptor.""" - - def _server(self) -> FastMCP: - mcp = FastMCP("test", tasks=False) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=TaskConfig(mode="required")) - async def required_tool() -> str: - return "required result" - - @mcp.tool(task=TaskConfig(mode="forbidden")) - async def forbidden_tool() -> str: - return "forbidden result" - - @mcp.tool(task=TaskConfig(mode="optional")) - async def optional_tool() -> str: - return "optional result" - - return mcp - - async def test_required_mode_without_opt_in_raises(self): - """Required mode raises -32021 when called without a tasks opt-in.""" - mcp = self._server() - async with running_task_server(mcp): - with pytest.raises(MCPError) as exc_info: - await call_tool_without_optin(mcp, "required_tool") - assert exc_info.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY - - async def test_required_mode_with_opt_in_tasks(self): - """Required mode tasks when the caller opts in.""" - mcp = self._server() - async with running_task_server(mcp): - created = await submit_task(mcp, "required_tool") - assert isinstance(created, CreateTaskResult) - - async def test_forbidden_mode_never_tasks_even_with_opt_in(self): - """Forbidden mode runs synchronously even when the caller opts in.""" - mcp = self._server() - async with running_task_server(mcp): - result = await _opted_in_call(mcp, "forbidden_tool") - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "forbidden result"} - - async def test_optional_mode_without_opt_in_runs_sync(self): - """Optional mode runs synchronously without a tasks opt-in.""" - mcp = self._server() - async with running_task_server(mcp): - result = await call_tool_without_optin(mcp, "optional_tool") - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "optional result"} - - async def test_optional_mode_with_opt_in_tasks(self): - """Optional mode tasks when the caller opts in.""" - mcp = self._server() - async with running_task_server(mcp): - created = await submit_task(mcp, "optional_tool") - assert isinstance(created, CreateTaskResult) - - -class TestToolExecutionMetadata: - """Test that ToolExecution.task_support is set correctly in tool metadata. - - The tools/list payload is produced by ``Tool.to_mcp_tool()``; these tests - assert on that serialization directly, which is what a server advertises on - the wire. (The FastMCP client session does not yet surface ``execution`` back - to callers, so a client round-trip cannot observe it until Phase 4.) - """ - - async def test_optional_tool_exposes_task_support(self): - """Tools with mode=optional expose task_support='optional'.""" - mcp = FastMCP("test", tasks=False) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=TaskConfig(mode="optional")) - async def my_tool() -> str: - return "ok" - - tool = await mcp.get_tool("my_tool") - assert tool is not None - execution = tool.to_mcp_tool().execution - assert isinstance(execution, ToolExecution) - assert execution.task_support == "optional" - - async def test_required_tool_exposes_task_support(self): - """Tools with mode=required expose task_support='required'.""" - mcp = FastMCP("test", tasks=False) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=TaskConfig(mode="required")) - async def my_tool() -> str: - return "ok" - - tool = await mcp.get_tool("my_tool") - assert tool is not None - execution = tool.to_mcp_tool().execution - assert isinstance(execution, ToolExecution) - assert execution.task_support == "required" - - async def test_forbidden_tool_has_no_execution(self): - """Tools with mode=forbidden do not expose execution metadata.""" - mcp = FastMCP("test", tasks=False) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=TaskConfig(mode="forbidden")) - async def my_tool() -> str: - return "ok" - - tool = await mcp.get_tool("my_tool") - assert tool is not None - assert tool.to_mcp_tool().execution is None - - -class TestSyncFunctionValidation: - """Test that sync functions cannot have task execution enabled.""" - - def test_sync_function_with_task_true_raises(self): - """Sync functions should raise ValueError when task=True.""" - mcp = FastMCP("test", tasks=False) - - with pytest.raises(ValueError, match="sync function"): - - @mcp.tool(task=True) - def sync_tool() -> str: - return "ok" - - def test_sync_function_with_required_mode_raises(self): - """Sync functions should raise ValueError with mode='required'.""" - mcp = FastMCP("test", tasks=False) - - with pytest.raises(ValueError, match="sync function"): - - @mcp.tool(task=TaskConfig(mode="required")) - def sync_tool() -> str: - return "ok" - - def test_sync_function_with_optional_mode_raises(self): - """Sync functions should raise ValueError with mode='optional'.""" - mcp = FastMCP("test", tasks=False) - - with pytest.raises(ValueError, match="sync function"): - - @mcp.tool(task=TaskConfig(mode="optional")) - def sync_tool() -> str: - return "ok" - - async def test_sync_function_with_forbidden_mode_ok(self): - """Sync functions should work fine with mode='forbidden'.""" - mcp = FastMCP("test", tasks=False) - - @mcp.tool(task=TaskConfig(mode="forbidden")) - def sync_tool() -> str: - return "ok" - - tool = await mcp.get_tool("sync_tool") - assert isinstance(tool, Tool) - assert tool.task_config.mode == "forbidden" - - -class TestPollIntervalConfiguration: - """Test poll_interval configuration in TaskConfig.""" - - async def test_default_poll_interval_is_5_seconds(self): - """Default poll_interval should be 5 seconds.""" - config = TaskConfig() - assert config.poll_interval == timedelta(seconds=5) - - async def test_custom_poll_interval_preserved(self): - """Custom poll_interval should be preserved in TaskConfig.""" - config = TaskConfig(poll_interval=timedelta(seconds=10)) - assert config.poll_interval == timedelta(seconds=10) - - async def test_tool_inherits_poll_interval(self): - """Tool should inherit poll_interval from TaskConfig.""" - mcp = FastMCP("test", tasks=False) - - @mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=2))) - async def my_tool() -> str: - return "ok" - - tool = await mcp.get_tool("my_tool") - assert isinstance(tool, Tool) - assert tool.task_config.poll_interval == timedelta(seconds=2) - - async def test_task_true_uses_default_poll_interval(self): - """task=True should use default 5 second poll_interval.""" - mcp = FastMCP("test", tasks=False) - - @mcp.tool(task=True) - async def my_tool() -> str: - return "ok" - - tool = await mcp.get_tool("my_tool") - assert isinstance(tool, Tool) - assert tool.task_config.poll_interval == timedelta(seconds=5) diff --git a/tests/tasks/server/test_task_dependencies.py b/tests/tasks/server/test_task_dependencies.py deleted file mode 100644 index 847b902d5..000000000 --- a/tests/tasks/server/test_task_dependencies.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Tests for dependency injection in background tasks. - -These tests verify that Docket's dependency system works correctly when tool -functions are queued as background tasks. Dependencies like CurrentDocket(), -CurrentFastMCP(), and Depends() should be resolved in the worker context. - -SEP-2663 is tools-only, so only tools carry a task-capable config; the removed -prompt/resource task cases are gone. -""" - -from __future__ import annotations - -from contextlib import asynccontextmanager -from typing import Any, cast - -import pytest -from fastmcp_tasks.dependencies import CurrentDocket -from uncalled_for import Depends - -from fastmcp import Context, FastMCP -from fastmcp.server.auth import AccessToken -from fastmcp.server.dependencies import CurrentFastMCP -from fastmcp.server.sessions import UserSession -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - call_tool_without_optin, - run_task, - running_task_server, -) - - -@pytest.fixture -def dependency_server() -> FastMCP: - """A FastMCP server with dependency-using background tools.""" - mcp = FastMCP("dependency-test-server") - mcp.add_extension(TasksExtension()) - - injected_values: list[tuple[str, Any]] = [] - - @mcp.tool(task=True) - async def tool_with_docket_dependency(docket=CurrentDocket()) -> str: - injected_values.append(("docket", docket)) - return f"Docket: {docket is not None}" - - @mcp.tool(task=True) - async def tool_with_server_dependency(server=CurrentFastMCP()) -> str: - injected_values.append(("server", server)) - return f"Server: {server.name}" - - @mcp.tool(task=True) - async def tool_with_custom_dependency( - value: int, multiplier: int = Depends(lambda: 10) - ) -> int: - injected_values.append(("multiplier", multiplier)) - return value * multiplier - - @mcp.tool(task=True) - async def tool_with_multiple_dependencies( - name: str, - docket=CurrentDocket(), - server=CurrentFastMCP(), - ) -> str: - injected_values.append(("multi_docket", docket)) - injected_values.append(("multi_server", server)) - return f"{name} on {server.name}" - - mcp._injected_values = injected_values # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - - return mcp - - -async def test_background_tool_receives_docket_dependency(dependency_server): - """Background tools can use CurrentDocket() and it resolves in the worker.""" - async with running_task_server(dependency_server): - final = await run_task(dependency_server, "tool_with_docket_dependency", {}) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "Docket: True"} - assert len(dependency_server._injected_values) == 1 - dep_type, dep_value = dependency_server._injected_values[0] - assert dep_type == "docket" - assert dep_value is not None - - -async def test_background_tool_receives_server_dependency(dependency_server): - """Background tools can use CurrentFastMCP() and get the actual server.""" - dependency_server._injected_values.clear() - - async with running_task_server(dependency_server): - final = await run_task(dependency_server, "tool_with_server_dependency", {}) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == { - "result": f"Server: {dependency_server.name}" - } - assert len(dependency_server._injected_values) == 1 - dep_type, dep_value = dependency_server._injected_values[0] - assert dep_type == "server" - assert dep_value is dependency_server # Same instance! - - -async def test_background_tool_receives_custom_depends(dependency_server): - """Background tools can use Depends() with custom functions.""" - dependency_server._injected_values.clear() - - async with running_task_server(dependency_server): - final = await run_task( - dependency_server, "tool_with_custom_dependency", {"value": 5} - ) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": 50} # 5 * 10 - assert len(dependency_server._injected_values) == 1 - dep_type, dep_value = dependency_server._injected_values[0] - assert dep_type == "multiplier" - assert dep_value == 10 - - -async def test_background_tool_with_multiple_dependencies(dependency_server): - """Background tools can have multiple dependencies injected at once.""" - dependency_server._injected_values.clear() - - async with running_task_server(dependency_server): - final = await run_task( - dependency_server, "tool_with_multiple_dependencies", {"name": "test"} - ) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == { - "result": f"test on {dependency_server.name}" - } - - dep_types = {item[0] for item in dependency_server._injected_values} - assert "multi_docket" in dep_types - assert "multi_server" in dep_types - - server_dep = next( - v for t, v in dependency_server._injected_values if t == "multi_server" - ) - assert server_dep is dependency_server - - -async def test_foreground_tool_dependencies_unaffected(dependency_server): - """Synchronous tools still get their dependencies as before.""" - dependency_server._injected_values.clear() - - @dependency_server.tool - async def sync_tool(server=CurrentFastMCP()) -> str: - dependency_server._injected_values.append(("sync_server", server)) - return f"Sync: {server.name}" - - async with running_task_server(dependency_server): - await call_tool_without_optin(dependency_server, "sync_tool", {}) - - assert len(dependency_server._injected_values) == 1 - assert dependency_server._injected_values[0][1] is dependency_server - - -async def test_dependency_context_managers_cleaned_up_in_background(): - """Context-manager dependencies are cleaned up after a background task.""" - cleanup_called: list[str] = [] - - mcp = FastMCP("cleanup-test") - mcp.add_extension(TasksExtension()) - - @asynccontextmanager - async def tracked_connection(): - try: - cleanup_called.append("enter") - yield "connection" - finally: - cleanup_called.append("exit") - - @mcp.tool(task=True) - async def use_connection(name: str, conn: str = Depends(tracked_connection)) -> str: - assert conn == "connection" - assert "enter" in cleanup_called - assert "exit" not in cleanup_called # Still open during execution - return f"Used: {conn}" - - async with running_task_server(mcp): - final = await run_task(mcp, "use_connection", {"name": "test"}) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "Used: connection"} - assert cleanup_called == ["enter", "exit"] - - -async def test_dependency_errors_propagate_to_task_failure(): - """If dependency resolution fails, the background task should fail.""" - mcp = FastMCP("error-test") - mcp.add_extension(TasksExtension()) - - async def failing_dependency(): - raise ValueError("Dependency failed!") - - @mcp.tool(task=True) - async def tool_with_failing_dep( - value: str, dep: str = cast(Any, Depends(failing_dependency)) - ) -> str: - return f"Got: {dep}" - - async with running_task_server(mcp): - final = await run_task(mcp, "tool_with_failing_dep", {"value": "test"}) - - assert final.status == "failed" - assert final.error is not None - - -async def test_user_session_state_persists_across_task_calls(): - """`session: UserSession` resolves in a worker and shares state per principal. - - A `UserSession` parameter is injected the same way in a background task as on - a foreground call: it resolves through the task-aware `get_server()` and the - authenticated principal restored from the task snapshot, with no live session - needed. Two tasked calls under one principal therefore share a state bucket. - """ - mcp = FastMCP("session-task") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def remember(fact: str, session: UserSession) -> list[str]: - facts = await session.get("facts", default=[]) - facts.append(fact) - await session.set("facts", facts) - return facts - - alice = AccessToken(token="a", client_id="alice", scopes=[], claims={"sub": "u1"}) - bob = AccessToken(token="b", client_id="bob", scopes=[], claims={"sub": "u2"}) - - async with running_task_server(mcp): - first = await run_task(mcp, "remember", {"fact": "apples"}, access_token=alice) - second = await run_task(mcp, "remember", {"fact": "pears"}, access_token=alice) - other = await run_task(mcp, "remember", {"fact": "figs"}, access_token=bob) - - assert first.result is not None - assert second.result is not None - assert other.result is not None - assert first.result["structuredContent"]["result"] == ["apples"] - # Alice's second call sees her first call's state. - assert second.result["structuredContent"]["result"] == ["apples", "pears"] - # Bob is a distinct principal — isolated bucket. - assert other.result["structuredContent"]["result"] == ["figs"] - - -async def test_ctx_session_state_works_in_background_task(): - """`ctx.session_id` and `ctx.get_state`/`set_state` work inside a worker. - - A worker has no live session, so the Context-level session API falls back to - the stable session id captured in the task snapshot. Session-scoped state a - task writes is therefore keyed to the submitting client and readable back. - """ - mcp = FastMCP("ctx-session-task") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def stash(value: str, ctx: Context) -> dict[str, object]: - await ctx.set_state("stashed", value) - return { - "session_id": ctx.session_id, - "read_back": await ctx.get_state("stashed"), - } - - async with running_task_server(mcp): - final = await run_task(mcp, "stash", {"value": "hello"}) - - assert final.status == "completed" - assert final.result is not None - structured = final.result["structuredContent"] - assert structured["read_back"] == "hello" - assert isinstance(structured["session_id"], str) and structured["session_id"] diff --git a/tests/tasks/server/test_task_methods.py b/tests/tasks/server/test_task_methods.py deleted file mode 100644 index 5a463cc28..000000000 --- a/tests/tasks/server/test_task_methods.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Task protocol methods for SEP-2663: tasks/get, tasks/cancel, tasks/update. - -SEP-1686's `tasks/result` and `tasks/list` are removed — `tasks/get` inlines the -completed result. This suite covers the surviving methods, driven in-process via -the task helpers because there is no client task-submission API until Phase 4. -""" - -from __future__ import annotations - -import asyncio - -import pytest -from fastmcp_tasks.models import UpdateTaskResult -from mcp.shared.exceptions import MCPError - -from fastmcp import FastMCP -from fastmcp.exceptions import ToolError -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - cancel_task, - get_task, - run_task, - running_task_server, - submit_task, - update_task, - wait_for_task, -) - - -def _methods_server() -> FastMCP: - mcp = FastMCP("endpoint-test-server") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def quick_tool(value: int) -> int: - return value * 2 - - @mcp.tool(task=True) - async def error_tool() -> str: - raise ToolError("Task failed!") - - return mcp - - -async def test_tasks_get_returns_status_and_inlined_result(): - """`tasks/get` reports status and inlines the completed tool result.""" - mcp = _methods_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "quick_tool", {"value": 21}) - got = await get_task(mcp, created.task_id) - assert got.task_id == created.task_id - assert got.status in {"working", "completed"} - - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": 42} - assert final.result is not None - assert final.result["isError"] is False - - -async def test_tasks_get_includes_poll_interval(): - """`tasks/get` includes the poll-interval hint.""" - mcp = _methods_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "quick_tool", {"value": 42}) - got = await get_task(mcp, created.task_id) - assert got.poll_interval_ms == 5000 - - -async def test_tasks_get_returns_is_error_result_for_raised_tool(): - """A raised tool error is a completed task with an is_error result (SEP-2663).""" - mcp = _methods_server() - async with running_task_server(mcp): - final = await run_task(mcp, "error_tool", {}) - assert final.status == "completed" - assert final.error is None - assert final.result is not None - assert final.result["isError"] is True - assert "Task failed!" in final.result["content"][0]["text"] - - -async def test_tasks_get_unknown_id_raises_not_found(): - """`tasks/get` for an unknown id raises a not-found error (-32602).""" - mcp = _methods_server() - async with running_task_server(mcp): - with pytest.raises(MCPError, match="not found"): - await get_task(mcp, "nonexistent-task-id") - - -async def test_tasks_cancel_transitions_to_cancelled(): - """`tasks/cancel` transitions a running task to cancelled.""" - mcp = FastMCP("cancel-test") - mcp.add_extension(TasksExtension()) - release = asyncio.Event() - - @mcp.tool(task=True) - async def slow_tool() -> str: - await release.wait() - return "done" - - async with running_task_server(mcp): - created = await submit_task(mcp, "slow_tool", {}) - await cancel_task(mcp, created.task_id) - # Release so the worker unwinds whether or not it observed the cancel first. - release.set() - final = await wait_for_task( - mcp, - created.task_id, - target_states=frozenset({"cancelled", "completed"}), - ) - assert final.status in {"cancelled", "completed"} - - -async def test_tasks_update_acks_empty(): - """`tasks/update` returns an empty ack.""" - mcp = FastMCP("update-test") - mcp.add_extension(TasksExtension()) - release = asyncio.Event() - - @mcp.tool(task=True) - async def waiter() -> str: - await release.wait() - return "done" - - async with running_task_server(mcp): - created = await submit_task(mcp, "waiter", {}) - ack = await update_task(mcp, created.task_id, {}) - assert isinstance(ack, UpdateTaskResult) - release.set() diff --git a/tests/tasks/server/test_task_middleware.py b/tests/tasks/server/test_task_middleware.py deleted file mode 100644 index c9324fc2a..000000000 --- a/tests/tasks/server/test_task_middleware.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Task-augmented calls flow through ToolResult-inspecting middleware safely. - -A `tools/call` the tasks extension turns into a background task returns a -`CreateTaskResult` up through the middleware chain. Middleware that post-process -a `ToolResult` (response caching, response limiting) must pass that -acknowledgement through untouched rather than crash after the task is enqueued. -""" - -from __future__ import annotations - -from fastmcp_tasks.models import CreateTaskResult - -from fastmcp import FastMCP -from fastmcp.server.middleware.caching import ResponseCachingMiddleware -from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import running_task_server, submit_task, wait_for_task - - -async def test_tasked_call_survives_result_inspecting_middleware(): - mcp = FastMCP("tasks-mw") - mcp.add_extension(TasksExtension()) - mcp.add_middleware(ResponseCachingMiddleware()) - mcp.add_middleware(ResponseLimitingMiddleware(max_size=1_000_000)) - - @mcp.tool(task=True) - async def crunch(n: int) -> int: - return n * n - - async with running_task_server(mcp): - created = await submit_task(mcp, "crunch", {"n": 9}) - assert isinstance(created, CreateTaskResult) - final = await wait_for_task(mcp, created.task_id) - - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": 81} diff --git a/tests/tasks/server/test_task_mount.py b/tests/tasks/server/test_task_mount.py deleted file mode 100644 index df8f23deb..000000000 --- a/tests/tasks/server/test_task_mount.py +++ /dev/null @@ -1,562 +0,0 @@ -"""SEP-2663 task execution through mounted servers (tools-only). - -Verifies that background tasks work when a tool lives on a mounted child server: -the parent (which registers the tasks extension and owns the Docket) runs the -tool as a task, the worker resolves back to the child server, dependencies -resolve, and mode enforcement / metadata survive mounting. SEP-2663 is -tools-only, so the SEP-1686 prompt/resource mount cases are gone. - -Two architectural notes vs. SEP-1686: -- The `tools/call` interceptor composes at the *registering* (parent/root) - server's dispatch and short-circuits before delegating into a mounted child, - so for a tasked call only the root's middleware wraps submission (the tool - body runs later in the worker). Child/grandchild middleware do not wrap a - tasked submission. -- Worker server resolution is single-level: a tool reached through nested mounts - resolves to the outermost mounted child (the mount point the call arrived - through), which still reaches deeper components via its own mounts. -""" - -from __future__ import annotations - -import asyncio -from typing import cast - -import mcp_types as mt -import pytest -from docket import Docket -from fastmcp_tasks.dependencies import CurrentDocket -from mcp_types import Tool as MCPTool -from mcp_types import ToolExecution - -from fastmcp import Context, FastMCP -from fastmcp.server.dependencies import CurrentFastMCP -from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.server.providers.proxy import ClientFactoryT, ProxyTool -from fastmcp.tools.base import ToolResult -from fastmcp.utilities.tasks import TaskConfig -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - call_tool_without_optin, - running_task_server, - submit_task, - wait_for_task, -) - - -@pytest.fixture(autouse=True) -def reset_docket_memory_server(): - """Reset the shared memory:// Docket server between tests for isolation.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - -@pytest.fixture -def child_server() -> FastMCP: - mcp = FastMCP("child-server") - - @mcp.tool(task=True) - async def multiply(a: int, b: int) -> int: - return a * b - - @mcp.tool(task=False) - async def sync_child_tool(message: str) -> str: - return f"child sync: {message}" - - return mcp - - -@pytest.fixture -def parent_server(child_server: FastMCP) -> FastMCP: - parent = FastMCP("parent-server") - parent.add_extension(TasksExtension()) - - @parent.tool(task=True) - async def parent_tool(value: int) -> int: - return value * 10 - - parent.mount(child_server, namespace="child") - return parent - - -class TestMountedToolTasks: - async def test_mounted_tool_task_returns_correct_result(self, parent_server): - async with running_task_server(parent_server): - created = await submit_task( - parent_server, "child_multiply", {"a": 8, "b": 9} - ) - assert created.status == "working" - final = await wait_for_task(parent_server, created.task_id) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"]["result"] == 72 - - async def test_mounted_and_parent_tasks_both_work(self, parent_server): - async with running_task_server(parent_server): - parent_created = await submit_task( - parent_server, "parent_tool", {"value": 5} - ) - child_created = await submit_task( - parent_server, "child_multiply", {"a": 2, "b": 3} - ) - parent_final = await wait_for_task(parent_server, parent_created.task_id) - child_final = await wait_for_task(parent_server, child_created.task_id) - assert parent_final.result is not None - assert parent_final.result["structuredContent"]["result"] == 50 - assert child_final.result is not None - assert child_final.result["structuredContent"]["result"] == 6 - - async def test_sync_only_mounted_tool_runs_synchronously(self, parent_server): - """A task=False mounted tool runs sync even when the client opts in.""" - async with running_task_server(parent_server): - # Opting in on a forbidden tool must not task it. - from tests.tasks.task_helpers import _opted_in_request - - with _opted_in_request("child_sync_child_tool", {"message": "hi"}, None): - result = await parent_server.call_tool( - "child_sync_child_tool", {"message": "hi"} - ) - assert not hasattr(result, "task_id") - assert "child sync: hi" in result.content[0].text - - -class TestRemoteWorkerServerResolution: - """A separate worker process re-resolves the owning child from the root. - - The in-process submission map is unreachable across processes, so the worker - recovers the mounted child server from the snapshotted tool name instead of - falling back to the root (which would break child-specific state/config). - """ - - async def test_resolve_owning_server_recovers_mounted_child(self, parent_server): - import weakref - - from fastmcp_tasks.context import ( - TaskContextSnapshot, - _resolve_owning_server, - ) - - from fastmcp.server.dependencies import _current_server - - child = await parent_server.get_tool("child_multiply") - - token = _current_server.set(weakref.ref(parent_server)) - try: - snapshot = TaskContextSnapshot(owning_tool_name="child_multiply") - resolved = await _resolve_owning_server(snapshot) - assert resolved is child._server - - # A parent-owned (unmounted) tool resolves to None so the caller - # falls back to the root, and a missing name is likewise None. - assert ( - await _resolve_owning_server( - TaskContextSnapshot(owning_tool_name="parent_tool") - ) - is None - ) - assert ( - await _resolve_owning_server( - TaskContextSnapshot(owning_tool_name="does_not_exist") - ) - is None - ) - assert await _resolve_owning_server(TaskContextSnapshot()) is None - finally: - _current_server.reset(token) - - async def test_resolve_owning_server_respects_version(self): - """Two versions of a mounted tool name resolve to their own child server.""" - import weakref - - from fastmcp_tasks.context import ( - TaskContextSnapshot, - _resolve_owning_server, - ) - - from fastmcp.server.dependencies import _current_server - - child_v1 = FastMCP("child-v1") - - @child_v1.tool(name="calc", version="1.0", task=True) - async def calc_v1() -> str: - return "v1" - - child_v2 = FastMCP("child-v2") - - @child_v2.tool(name="calc", version="2.0", task=True) - async def calc_v2() -> str: - return "v2" - - parent = FastMCP("parent-versions") - parent.add_extension(TasksExtension()) - parent.mount(child_v1) - parent.mount(child_v2) - - token = _current_server.set(weakref.ref(parent)) - try: - resolved_v1 = await _resolve_owning_server( - TaskContextSnapshot(owning_tool_name="calc", owning_tool_version="1.0") - ) - resolved_v2 = await _resolve_owning_server( - TaskContextSnapshot(owning_tool_name="calc", owning_tool_version="2.0") - ) - assert resolved_v1 is child_v1 - assert resolved_v2 is child_v2 - finally: - _current_server.reset(token) - - -class TestMountedToolTasksNoPrefix: - async def test_mounted_tool_without_prefix_works(self, child_server): - parent = FastMCP("parent-no-prefix") - parent.add_extension(TasksExtension()) - parent.mount(child_server) # no prefix - async with running_task_server(parent): - final = await wait_for_task( - parent, - (await submit_task(parent, "multiply", {"a": 5, "b": 6})).task_id, - ) - assert final.result is not None - assert final.result["structuredContent"]["result"] == 30 - - -class TestMountedTaskDependencies: - async def test_mounted_task_receives_docket_dependency(self): - child = FastMCP("dep-child") - - @child.tool(task=True) - async def tool_with_docket(docket: Docket = CurrentDocket()) -> str: - return f"docket available: {docket is not None}" - - parent = FastMCP("dep-parent") - parent.add_extension(TasksExtension()) - parent.mount(child, namespace="child") - - async with running_task_server(parent): - final = await wait_for_task( - parent, - (await submit_task(parent, "child_tool_with_docket", {})).task_id, - ) - assert final.result is not None - assert "docket available: True" in final.result["content"][0]["text"] - - -class TestMountedTaskServerContext: - async def test_current_fastmcp_resolves_to_child_server(self): - child = FastMCP("child") - - @child.tool(task=True) - async def whoami(server: FastMCP = CurrentFastMCP()) -> str: - return f"server name: {server.name}" - - parent = FastMCP("parent") - parent.add_extension(TasksExtension()) - parent.mount(child, namespace="child") - - async with running_task_server(parent): - final = await wait_for_task( - parent, (await submit_task(parent, "child_whoami", {})).task_id - ) - assert final.result is not None - assert "server name: child" in final.result["content"][0]["text"] - - async def test_context_fastmcp_resolves_to_child_server(self): - child = FastMCP("child") - - @child.tool(task=True) - async def whoami_ctx(ctx: Context) -> str: - return f"context server: {ctx.fastmcp.name}" - - parent = FastMCP("parent") - parent.add_extension(TasksExtension()) - parent.mount(child, namespace="child") - - async with running_task_server(parent): - final = await wait_for_task( - parent, (await submit_task(parent, "child_whoami_ctx", {})).task_id - ) - assert final.result is not None - assert "context server: child" in final.result["content"][0]["text"] - - -class TestMultipleMounts: - async def test_tasks_work_with_multiple_mounts(self): - child1 = FastMCP("child1") - child2 = FastMCP("child2") - - @child1.tool(task=True) - async def add(a: int, b: int) -> int: - return a + b - - @child2.tool(task=True) - async def subtract(a: int, b: int) -> int: - return a - b - - parent = FastMCP("multi-parent") - parent.add_extension(TasksExtension()) - parent.mount(child1, namespace="math1") - parent.mount(child2, namespace="math2") - - async with running_task_server(parent): - r1 = await wait_for_task( - parent, - (await submit_task(parent, "math1_add", {"a": 10, "b": 5})).task_id, - ) - r2 = await wait_for_task( - parent, - ( - await submit_task(parent, "math2_subtract", {"a": 10, "b": 5}) - ).task_id, - ) - assert r1.result is not None - assert r1.result["structuredContent"]["result"] == 15 - assert r2.result is not None - assert r2.result["structuredContent"]["result"] == 5 - - async def test_same_function_names_do_not_collide(self): - child1 = FastMCP("child1") - child2 = FastMCP("child2") - - @child1.tool(task=True) - async def process(value: int) -> int: - return value * 2 - - @child2.tool(task=True) - async def process(value: int) -> int: # noqa: F811 - return value * 3 - - parent = FastMCP("parent") - parent.add_extension(TasksExtension()) - parent.mount(child1, namespace="c1") - parent.mount(child2, namespace="c2") - - async with running_task_server(parent): - r1 = await wait_for_task( - parent, - (await submit_task(parent, "c1_process", {"value": 10})).task_id, - ) - r2 = await wait_for_task( - parent, - (await submit_task(parent, "c2_process", {"value": 10})).task_id, - ) - assert r1.result is not None - assert r1.result["structuredContent"]["result"] == 20 - assert r2.result is not None - assert r2.result["structuredContent"]["result"] == 30 - - async def test_nested_mount_prefix_accumulation(self): - grandchild = FastMCP("gc") - child = FastMCP("child") - parent = FastMCP("parent") - parent.add_extension(TasksExtension()) - - @grandchild.tool(task=True) - async def deep_tool() -> str: - return "deep" - - child.mount(grandchild, namespace="gc") - parent.mount(child, namespace="child") - - async with running_task_server(parent): - final = await wait_for_task( - parent, - (await submit_task(parent, "child_gc_deep_tool", {})).task_id, - ) - assert final.result is not None - assert final.result["structuredContent"]["result"] == "deep" - - -class TestMountedTaskMetadata: - async def test_mounted_tool_list_preserves_task_support_metadata(self): - child = FastMCP("child") - - @child.tool(task=True) - async def foo() -> dict[str, bool]: - return {"ok": True} - - parent = FastMCP("parent") - parent.mount(child) - - child_tool = next(t for t in await child.list_tools() if t.name == "foo") - parent_tool = next(t for t in await parent.list_tools() if t.name == "foo") - - child_mcp = child_tool.to_mcp_tool(name=child_tool.name) - parent_mcp = parent_tool.to_mcp_tool(name=parent_tool.name) - assert child_mcp.execution is not None - assert parent_mcp.execution is not None - assert child_mcp.execution.task_support == "optional" - assert parent_mcp.execution.task_support == "optional" - - async def test_proxy_tool_preserves_execution_metadata(self): - mcp_tool = MCPTool( - name="remote_task_tool", - description="A remote tool that supports tasks", - input_schema={"type": "object", "properties": {}}, - execution=ToolExecution(task_support="optional"), - ) - proxy = ProxyTool.from_mcp_tool(cast(ClientFactoryT, lambda: None), mcp_tool) - result = proxy.to_mcp_tool(name=proxy.name) - assert result.execution is not None - assert result.execution.task_support == "optional" - - -class TestMountedTaskConfigModes: - @pytest.fixture - def parent_with_modes(self) -> FastMCP: - child = FastMCP("child-modes") - - @child.tool(task=TaskConfig(mode="optional")) - async def optional_tool() -> str: - return "optional result" - - @child.tool(task=TaskConfig(mode="required")) - async def required_tool() -> str: - return "required result" - - @child.tool(task=TaskConfig(mode="forbidden")) - async def forbidden_tool() -> str: - return "forbidden result" - - parent = FastMCP("parent-modes") - parent.add_extension(TasksExtension()) - parent.mount(child, namespace="child") - return parent - - async def test_optional_mode_sync_through_mount(self, parent_with_modes): - async with running_task_server(parent_with_modes): - result = await call_tool_without_optin( - parent_with_modes, "child_optional_tool", {} - ) - assert "optional result" in result.content[0].text - - async def test_optional_mode_task_through_mount(self, parent_with_modes): - async with running_task_server(parent_with_modes): - final = await wait_for_task( - parent_with_modes, - ( - await submit_task(parent_with_modes, "child_optional_tool", {}) - ).task_id, - ) - assert final.result is not None - assert final.result["structuredContent"]["result"] == "optional result" - - async def test_required_mode_with_task_through_mount(self, parent_with_modes): - async with running_task_server(parent_with_modes): - final = await wait_for_task( - parent_with_modes, - ( - await submit_task(parent_with_modes, "child_required_tool", {}) - ).task_id, - ) - assert final.result is not None - assert final.result["structuredContent"]["result"] == "required result" - - async def test_required_mode_without_task_through_mount(self, parent_with_modes): - from fastmcp_tasks.models import MISSING_REQUIRED_CLIENT_CAPABILITY - from mcp.shared.exceptions import MCPError - - async with running_task_server(parent_with_modes): - with pytest.raises(MCPError) as exc_info: - await call_tool_without_optin( - parent_with_modes, "child_required_tool", {} - ) - assert exc_info.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY - - async def test_forbidden_mode_sync_through_mount(self, parent_with_modes): - async with running_task_server(parent_with_modes): - result = await call_tool_without_optin( - parent_with_modes, "child_forbidden_tool", {} - ) - assert "forbidden result" in result.content[0].text - - -class ToolTracingMiddleware(Middleware): - def __init__(self, name: str, calls: list[str]): - super().__init__() - self._name = name - self._calls = calls - - async def on_call_tool( - self, - context: MiddlewareContext[mt.CallToolRequestParams], - call_next: CallNext[mt.CallToolRequestParams, ToolResult], - ) -> ToolResult: - self._calls.append(f"{self._name}:before") - result = await call_next(context) - self._calls.append(f"{self._name}:after") - return result - - -class TestMiddlewareWithMountedTasks: - async def test_root_middleware_wraps_task_submission(self): - """For a tasked call, the root's middleware wraps submission. - - The interceptor composes at the registering (parent) server and - short-circuits before delegating into the mounted child, so child - middleware does not wrap a tasked submission; the tool body runs later - in the worker. - """ - calls: list[str] = [] - - grandchild = FastMCP("Grandchild") - - @grandchild.tool(task=True) - async def compute(x: int) -> int: - calls.append("grandchild:tool") - return x * 2 - - grandchild.add_middleware(ToolTracingMiddleware("grandchild", calls)) - child = FastMCP("Child") - child.mount(grandchild, namespace="gc") - child.add_middleware(ToolTracingMiddleware("child", calls)) - parent = FastMCP("Parent") - parent.add_extension(TasksExtension()) - parent.mount(child, namespace="c") - parent.add_middleware(ToolTracingMiddleware("parent", calls)) - - async with running_task_server(parent): - created = await submit_task(parent, "c_gc_compute", {"x": 5}) - final = await wait_for_task(parent, created.task_id) - assert final.result is not None - assert final.result["structuredContent"]["result"] == 10 - - assert calls == ["parent:before", "parent:after", "grandchild:tool"] - - -class TestMountedDocketOwnership: - async def test_mounted_child_does_not_own_docket(self, parent_server, child_server): - """The parent owns the Docket; the mounted child does not.""" - async with running_task_server(parent_server): - assert parent_server.docket is not None - assert child_server.docket is None - - -class TestSlowMountedTaskCancellation: - async def test_cancel_mounted_task(self): - child = FastMCP("child") - release = asyncio.Event() - - @child.tool(task=True) - async def slow() -> str: - await release.wait() - return "done" - - parent = FastMCP("parent") - parent.add_extension(TasksExtension()) - parent.mount(child, namespace="child") - - from tests.tasks.task_helpers import cancel_task - - async with running_task_server(parent): - created = await submit_task(parent, "child_slow", {}) - await cancel_task(parent, created.task_id) - release.set() - final = await wait_for_task( - parent, - created.task_id, - target_states=frozenset({"cancelled", "completed"}), - ) - assert final.status in {"cancelled", "completed"} diff --git a/tests/tasks/server/test_task_protocol.py b/tests/tasks/server/test_task_protocol.py deleted file mode 100644 index 62892dcfd..000000000 --- a/tests/tasks/server/test_task_protocol.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Protocol-level task behavior for SEP-2663 tasks. - -Generic protocol behaviors driven in-process via the task helpers: a submitted -task carries a server-generated id and a TTL, and a task whose tool raises -surfaces its error rather than a result. -""" - -from __future__ import annotations - -from fastmcp import FastMCP -from fastmcp.exceptions import ToolError -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - run_task, - running_task_server, - submit_task, -) - - -def _task_server() -> FastMCP: - mcp = FastMCP("task-test-server") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def simple_tool(message: str) -> str: - return f"Processed: {message}" - - @mcp.tool(task=True) - async def failing_tool() -> str: - raise ToolError("This tool always fails") - - return mcp - - -async def test_task_metadata_includes_task_id_and_ttl(): - """A submitted task carries a server-generated id and a positive TTL.""" - mcp = _task_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "simple_tool", {"message": "test"}) - assert isinstance(created.task_id, str) - assert created.task_id - assert created.ttl_ms is not None and created.ttl_ms > 0 - - -async def test_raised_tool_error_completes_with_is_error(): - """A task whose tool raises completes with an is_error result (SEP-2663). - - `failed` is reserved for protocol faults; a raised tool error is the same - `isError` result a live tools/call returns. - """ - mcp = _task_server() - async with running_task_server(mcp): - final = await run_task(mcp, "failing_tool", {}) - assert final.status == "completed" - assert final.error is None - assert final.result is not None - assert final.result["isError"] is True - assert "This tool always fails" in final.result["content"][0]["text"] diff --git a/tests/tasks/server/test_task_proxy.py b/tests/tasks/server/test_task_proxy.py deleted file mode 100644 index 078e48894..000000000 --- a/tests/tasks/server/test_task_proxy.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -Tests for SEP-2663 task behavior through proxy servers. - -SEP-2663 tasks are tools-only. Proxy servers force every proxied tool to -`task_config.mode="forbidden"`, so a tool that is `task=True` on the backend -runs *synchronously* through the proxy and is never tasked — even when the -client opts the tasks extension in for the request. -""" - -import pytest -from docket import Docket -from fastmcp_tasks.models import CreateTaskResult - -from fastmcp import FastMCP -from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport -from fastmcp.server import create_proxy -from fastmcp.tools.base import ToolResult -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - _opted_in_request, - auth_scope, - running_task_server, -) - - -@pytest.fixture(autouse=True) -def reset_docket_memory_server(): - """Force a fresh memory:// Docket server bound to each test's event loop.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - -async def call_tool_with_optin(server: FastMCP, name: str, arguments: dict): - """Run a `tools/call` with the tasks opt-in bound into the request context.""" - with auth_scope(None), _opted_in_request(name, arguments, None): - return await server.call_tool(name, arguments) - - -@pytest.fixture -def backend_server() -> FastMCP: - """A backend server with a task-enabled tool. - - The backend has tasks enabled, but the proxy must NOT forward task - execution — it treats every proxied tool as forbidden. - """ - mcp = FastMCP("backend-server") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def add_numbers(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - @mcp.tool(task=False) - async def sync_only_tool(message: str) -> str: - """Tool that only supports synchronous execution.""" - return f"sync: {message}" - - return mcp - - -@pytest.fixture -def proxy_server(backend_server: FastMCP) -> FastMCP: - """A proxy server that forwards to the backend, with tasks advertised.""" - proxy = create_proxy(FastMCPTransport(backend_server)) - proxy.add_extension(TasksExtension()) - return proxy - - -class TestProxyToolsSyncExecution: - """Tools work normally through the proxy (synchronous execution).""" - - async def test_tool_sync_execution_works(self, proxy_server: FastMCP): - """A tool called without opting in works through the proxy.""" - async with Client(proxy_server) as client: - result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) - assert "8" in str(result) - - async def test_sync_only_tool_works(self, proxy_server: FastMCP): - """A sync-only tool works through the proxy.""" - async with Client(proxy_server) as client: - result = await client.call_tool("sync_only_tool", {"message": "test"}) - assert "sync: test" in str(result) - - -class TestProxyToolsTaskForbidden: - """A proxied tool never tasks, even when the client opts in.""" - - async def test_task_enabled_tool_runs_sync_through_proxy( - self, proxy_server: FastMCP - ): - """A backend `task=True` tool runs sync through the forbidden proxy.""" - async with running_task_server(proxy_server): - result = await call_tool_with_optin( - proxy_server, "add_numbers", {"a": 5, "b": 3} - ) - - # The forbidden proxy tool declines to task even with the opt-in. - assert not isinstance(result, CreateTaskResult) - assert isinstance(result, ToolResult) - assert result.structured_content == {"result": 8} - - async def test_sync_only_tool_runs_sync_through_proxy(self, proxy_server: FastMCP): - """A sync-only tool also runs sync through the proxy with the opt-in.""" - async with running_task_server(proxy_server): - result = await call_tool_with_optin( - proxy_server, "sync_only_tool", {"message": "test"} - ) - - assert not isinstance(result, CreateTaskResult) - assert isinstance(result, ToolResult) - assert result.structured_content == {"result": "sync: test"} diff --git a/tests/tasks/server/test_task_return_types.py b/tests/tasks/server/test_task_return_types.py deleted file mode 100644 index eb65fabd9..000000000 --- a/tests/tasks/server/test_task_return_types.py +++ /dev/null @@ -1,438 +0,0 @@ -""" -Tests to verify all tool return types work identically with task=True. - -SEP-2663 tasks are tools-only. Every tool below is exercised twice: once -synchronously (no tasks opt-in) and once as a background task. Both paths run -the same `tool.convert_result(...).to_mcp_result()` pipeline, so the inlined -task result must be byte-for-byte identical to the synchronous result. These -tests assert that equivalence across every supported return type. -""" - -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any -from uuid import UUID - -import mcp_types -import pytest -from docket import Docket -from pydantic import BaseModel -from typing_extensions import TypedDict - -from fastmcp import FastMCP -from fastmcp.tools.base import ToolResult -from fastmcp.utilities.types import Audio, File, Image -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - call_tool_without_optin, - run_task, - running_task_server, -) - - -@pytest.fixture(autouse=True) -def reset_docket_memory_server(): - """Force a fresh memory:// Docket server bound to each test's event loop.""" - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - yield - if hasattr(Docket, "_memory_server"): - delattr(Docket, "_memory_server") - - -def _sync_result_to_wire(result: ToolResult) -> dict[str, Any]: - """Serialize a synchronous ToolResult into the inlined task wire shape.""" - mcp_result = result.to_mcp_result() - if isinstance(mcp_result, mcp_types.CallToolResult): - call_tool_result = mcp_result - elif isinstance(mcp_result, tuple): - content, structured_content = mcp_result - call_tool_result = mcp_types.CallToolResult( - content=content, - structured_content=structured_content, - ) - else: - call_tool_result = mcp_types.CallToolResult(content=mcp_result) - return call_tool_result.model_dump(by_alias=True, mode="json", exclude_none=True) - - -async def assert_task_matches_sync( - server: FastMCP, - tool_name: str, - arguments: dict[str, Any] | None = None, -) -> None: - """Run a tool sync and as a task; assert the inlined results are identical.""" - async with running_task_server(server): - sync_result = await call_tool_without_optin(server, tool_name, arguments) - assert isinstance(sync_result, ToolResult) - - task_result = await run_task(server, tool_name, arguments) - assert task_result.status == "completed" - assert task_result.result is not None - - assert task_result.result == _sync_result_to_wire(sync_result) - - -class UserData(BaseModel): - """Example structured output.""" - - name: str - age: int - active: bool - - -# ============================================================================== -# Basic Types -# ============================================================================== - - -@pytest.fixture -def return_type_server(): - """Server with tools that return various basic types.""" - mcp = FastMCP("return-type-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def return_string() -> str: - return "Hello, World!" - - @mcp.tool(task=True) - async def return_int() -> int: - return 42 - - @mcp.tool(task=True) - async def return_float() -> float: - return 3.14159 - - @mcp.tool(task=True) - async def return_bool() -> bool: - return True - - @mcp.tool(task=True) - async def return_dict() -> dict[str, int]: - return {"count": 100, "total": 500} - - @mcp.tool(task=True) - async def return_list() -> list[str]: - return ["apple", "banana", "cherry"] - - @mcp.tool(task=True) - async def return_model() -> UserData: - return UserData(name="Alice", age=30, active=True) - - @mcp.tool(task=True) - async def return_none() -> None: - return None - - return mcp - - -@pytest.mark.parametrize( - "tool_name", - [ - "return_string", - "return_int", - "return_float", - "return_bool", - "return_dict", - "return_list", - "return_model", - "return_none", - ], -) -async def test_task_basic_types_match_sync( - return_type_server: FastMCP, - tool_name: str, -): - """Task mode returns basic types identically to the synchronous path.""" - await assert_task_matches_sync(return_type_server, tool_name) - - -# ============================================================================== -# Binary & Special Types -# ============================================================================== - - -@pytest.fixture -def binary_type_server(tmp_path): - """Server with tools returning binary and special types.""" - mcp = FastMCP("binary-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def return_bytes() -> bytes: - return b"Hello bytes!" - - @mcp.tool(task=True) - async def return_uuid() -> UUID: - return UUID("12345678-1234-5678-1234-567812345678") - - @mcp.tool(task=True) - async def return_path() -> Path: - return Path("/tmp/test.txt") - - @mcp.tool(task=True) - async def return_datetime() -> datetime: - return datetime(2025, 11, 5, 12, 30, 45) - - return mcp - - -@pytest.mark.parametrize( - "tool_name", - ["return_bytes", "return_uuid", "return_path", "return_datetime"], -) -async def test_task_binary_types_match_sync( - binary_type_server: FastMCP, - tool_name: str, -): - """Task mode handles binary and special types identically to sync.""" - await assert_task_matches_sync(binary_type_server, tool_name) - - -# ============================================================================== -# Collection Varieties -# ============================================================================== - - -@pytest.fixture -def collection_server(): - """Server with tools returning various collection types.""" - mcp = FastMCP("collection-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def return_tuple() -> tuple[int, str, bool]: - return (42, "hello", True) - - @mcp.tool(task=True) - async def return_set() -> set[int]: - return {1, 2, 3} - - @mcp.tool(task=True) - async def return_empty_list() -> list[str]: - return [] - - @mcp.tool(task=True) - async def return_empty_dict() -> dict[str, Any]: - return {} - - return mcp - - -@pytest.mark.parametrize( - "tool_name", - ["return_tuple", "return_set", "return_empty_list", "return_empty_dict"], -) -async def test_task_collection_types_match_sync( - collection_server: FastMCP, - tool_name: str, -): - """Task mode handles collection types identically to sync.""" - await assert_task_matches_sync(collection_server, tool_name) - - -# ============================================================================== -# Media Types (Image, Audio, File) -# ============================================================================== - - -@pytest.fixture -def media_server(tmp_path): - """Server with tools returning media types.""" - mcp = FastMCP("media-test") - mcp.add_extension(TasksExtension()) - - test_image = tmp_path / "test.png" - test_image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fake png data") - - test_audio = tmp_path / "test.mp3" - test_audio.write_bytes(b"ID3" + b"fake mp3 data") - - test_file = tmp_path / "test.txt" - test_file.write_text("test file content") - - @mcp.tool(task=True) - async def return_image_path() -> Image: - return Image(path=str(test_image)) - - @mcp.tool(task=True) - async def return_image_data() -> Image: - return Image(data=test_image.read_bytes(), format="png") - - @mcp.tool(task=True) - async def return_audio() -> Audio: - return Audio(path=str(test_audio)) - - @mcp.tool(task=True) - async def return_file() -> File: - return File(path=str(test_file)) - - return mcp - - -@pytest.mark.parametrize( - "tool_name", - ["return_image_path", "return_image_data", "return_audio", "return_file"], -) -async def test_task_media_types_match_sync( - media_server: FastMCP, - tool_name: str, -): - """Task mode handles media types (Image, Audio, File) identically to sync.""" - await assert_task_matches_sync(media_server, tool_name) - - -# ============================================================================== -# Structured Types (TypedDict, dataclass, unions) -# ============================================================================== - - -class PersonTypedDict(TypedDict): - """Example TypedDict.""" - - name: str - age: int - - -@dataclass -class PersonDataclass: - """Example dataclass.""" - - name: str - age: int - - -@pytest.fixture -def structured_type_server(): - """Server with tools returning structured types.""" - mcp = FastMCP("structured-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def return_typeddict() -> PersonTypedDict: - return {"name": "Bob", "age": 25} - - @mcp.tool(task=True) - async def return_dataclass() -> PersonDataclass: - return PersonDataclass(name="Charlie", age=35) - - @mcp.tool(task=True) - async def return_union() -> str | int: - return "string value" - - @mcp.tool(task=True) - async def return_union_int() -> str | int: - return 123 - - @mcp.tool(task=True) - async def return_optional() -> str | None: - return "has value" - - @mcp.tool(task=True) - async def return_optional_none() -> str | None: - return None - - return mcp - - -@pytest.mark.parametrize( - "tool_name", - [ - "return_typeddict", - "return_dataclass", - "return_union", - "return_union_int", - "return_optional", - "return_optional_none", - ], -) -async def test_task_structured_types_match_sync( - structured_type_server: FastMCP, - tool_name: str, -): - """Task mode handles TypedDict, dataclass, union and optional returns.""" - await assert_task_matches_sync(structured_type_server, tool_name) - - -# ============================================================================== -# MCP Content Blocks -# ============================================================================== - - -@pytest.fixture -def mcp_content_server(tmp_path): - """Server with tools returning MCP content blocks.""" - import base64 - - from mcp_types import ( - EmbeddedResource, - ImageContent, - ResourceLink, - TextContent, - TextResourceContents, - ) - - mcp = FastMCP("content-test") - mcp.add_extension(TasksExtension()) - - test_image = tmp_path / "content.png" - test_image.write_bytes(b"\x89PNG\r\n\x1a\n" + b"content") - - @mcp.tool(task=True) - async def return_text_content() -> TextContent: - return TextContent(type="text", text="Direct text content") - - @mcp.tool(task=True) - async def return_image_content() -> ImageContent: - return ImageContent( - type="image", - data=base64.b64encode(test_image.read_bytes()).decode(), - mime_type="image/png", - ) - - @mcp.tool(task=True) - async def return_embedded_resource() -> EmbeddedResource: - return EmbeddedResource( - type="resource", - resource=TextResourceContents(uri="test://resource", text="embedded"), - ) - - @mcp.tool(task=True) - async def return_resource_link() -> ResourceLink: - return ResourceLink( - type="resource_link", uri="test://linked", name="Test Resource" - ) - - @mcp.tool(task=True) - async def return_mixed_content() -> list[TextContent | ImageContent]: - return [ - TextContent(type="text", text="First block"), - ImageContent( - type="image", - data=base64.b64encode(test_image.read_bytes()).decode(), - mime_type="image/png", - ), - TextContent(type="text", text="Third block"), - ] - - return mcp - - -@pytest.mark.parametrize( - "tool_name", - [ - "return_text_content", - "return_image_content", - "return_embedded_resource", - "return_resource_link", - "return_mixed_content", - ], -) -async def test_task_mcp_content_types_match_sync( - mcp_content_server: FastMCP, - tool_name: str, -): - """Task mode handles MCP content block types identically to sync.""" - await assert_task_matches_sync(mcp_content_server, tool_name) diff --git a/tests/tasks/server/test_task_security.py b/tests/tasks/server/test_task_security.py deleted file mode 100644 index f20ea606c..000000000 --- a/tests/tasks/server/test_task_security.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Authorization-based task isolation (CRITICAL SECURITY). - -Tasks are scoped to the caller's authorization identity via the auth-scoped -compound Docket key, so a caller can only resolve tasks it created. A cross-scope -task id is indistinguishable from a missing one (-32602 "not found"), which keeps -task existence from leaking across callers. These tests drive the task lifecycle -in-process (there is no client task API until Phase 4), binding a different -access token per caller through the shared helper. -""" - -from __future__ import annotations - -import pytest -from mcp.shared.exceptions import MCPError - -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - get_task, - make_access_token, - run_task, - running_task_server, - submit_task, - wait_for_task, -) - - -@pytest.fixture -def task_server() -> FastMCP: - mcp = FastMCP("security-test-server") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def secret_tool(data: str) -> str: - return f"Secret result: {data}" - - return mcp - - -async def test_same_client_can_access_all_its_tasks(task_server: FastMCP): - """A single authenticated caller can resolve every task it created.""" - token = make_access_token("client-a") - async with running_task_server(task_server): - first = await run_task( - task_server, "secret_tool", {"data": "first"}, access_token=token - ) - second = await run_task( - task_server, "secret_tool", {"data": "second"}, access_token=token - ) - assert first.result is not None - assert "first" in first.result["content"][0]["text"] - assert second.result is not None - assert "second" in second.result["content"][0]["text"] - - -async def test_unauthenticated_client_can_access_its_tasks(task_server: FastMCP): - """An anonymous caller can resolve tasks in the anonymous keyspace.""" - async with running_task_server(task_server): - final = await run_task(task_server, "secret_tool", {"data": "hello"}) - assert final.result is not None - assert "hello" in final.result["content"][0]["text"] - - -async def test_distinct_clients_cannot_access_each_others_tasks( - task_server: FastMCP, -): - """Two distinct client_ids live in disjoint scopes: a peer's id is 'not found'.""" - alice = make_access_token("client-a") - bob = make_access_token("client-b") - async with running_task_server(task_server): - created = await submit_task( - task_server, "secret_tool", {"data": "a-secret"}, access_token=alice - ) - with pytest.raises(MCPError, match="not found"): - await get_task(task_server, created.task_id, access_token=bob) - - -async def test_distinct_subs_same_client_id_cannot_access_each_others_tasks( - task_server: FastMCP, -): - """Fixed-OAuth case: one client_id, distinct ``sub`` claims stay isolated.""" - shared = "shared-oauth-app" - alice = make_access_token(shared, sub="user-alice") - bob = make_access_token(shared, sub="user-bob") - async with running_task_server(task_server): - created = await submit_task( - task_server, "secret_tool", {"data": "alice-secret"}, access_token=alice - ) - with pytest.raises(MCPError, match="not found"): - await get_task(task_server, created.task_id, access_token=bob) - - -async def test_authenticated_and_anonymous_keyspaces_are_disjoint( - task_server: FastMCP, -): - """An anonymous caller cannot read an authenticated caller's task.""" - authed = make_access_token("client-a") - async with running_task_server(task_server): - created = await submit_task( - task_server, "secret_tool", {"data": "authed-secret"}, access_token=authed - ) - # No access_token -> anonymous keyspace -> cannot resolve the authed task. - with pytest.raises(MCPError, match="not found"): - await get_task(task_server, created.task_id) - # And the authenticated caller still resolves it. - seen = await wait_for_task(task_server, created.task_id, access_token=authed) - assert seen.status == "completed" diff --git a/tests/tasks/server/test_task_tools.py b/tests/tasks/server/test_task_tools.py deleted file mode 100644 index 2ffd0f192..000000000 --- a/tests/tasks/server/test_task_tools.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Server-side tool task behavior for SEP-2663 tasks. - -Covers task=True/False decoration, argument coercion parity between the -synchronous and task-submission paths (including the strict-validation flag), -immediate task metadata on submission, background execution with status polling, -and the rule that a forbidden (task=False) tool runs synchronously even when the -caller opts into tasks. Driven in-process via the task helpers because there is -no client task-submission API until Phase 4. -""" - -from __future__ import annotations - -import asyncio -import functools - -import pytest -from fastmcp_tasks.models import CreateTaskResult -from pydantic import BaseModel - -from fastmcp import FastMCP -from fastmcp.exceptions import ValidationError -from fastmcp.tools.function_tool import _resolve_param_hints -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - _opted_in_request, - auth_scope, - call_tool_without_optin, - get_task, - run_task, - running_task_server, - submit_task, - wait_for_task, -) - - -class _Item(BaseModel): - value: str - - -async def _opted_in_call(server: FastMCP, name: str, arguments: dict | None = None): - """Run a `tools/call` WITH the tasks opt-in bound (used to prove sync paths).""" - with auth_scope(None), _opted_in_request(name, arguments or {}, None): - return await server.call_tool(name, arguments or {}) - - -def _tool_server() -> FastMCP: - mcp = FastMCP("tool-task-server") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def simple_tool(message: str) -> str: - return f"Processed: {message}" - - @mcp.tool(task=False) - async def sync_only_tool(message: str) -> str: - return f"Sync: {message}" - - return mcp - - -# --------------------------------------------------------------------------- -# Argument coercion parity -# --------------------------------------------------------------------------- - - -async def test_task_tool_coerces_model_arguments(): - """Model-typed args are coerced to model instances on the task path (#4349). - - The synchronous path validates arguments through the function's TypeAdapter, - so a parameter typed as a Pydantic model arrives as a model instance. The - task path must coerce identically rather than passing the raw dict through. - """ - mcp = FastMCP("tool-task-validation-server") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def inspect_items(item: _Item, items: list[_Item]) -> dict[str, str]: - return {"item": type(item).__name__, "element": type(items[0]).__name__} - - arguments = {"item": {"value": "a"}, "items": [{"value": "b"}]} - expected = {"item": "_Item", "element": "_Item"} - async with running_task_server(mcp): - sync_result = await call_tool_without_optin(mcp, "inspect_items", arguments) - final = await run_task(mcp, "inspect_items", arguments) - - assert sync_result.structured_content == expected - assert final.result is not None - assert final.result["structuredContent"] == expected - - -async def test_task_arguments_are_coerced_like_sync_path(): - """A string-for-int arg coerces on the task path exactly as on the sync path.""" - mcp = FastMCP("coerce-task-server") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def square(n: int) -> int: - return n * n - - async with running_task_server(mcp): - final = await run_task(mcp, "square", {"n": "1"}) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": 1} - - -async def test_task_submission_honors_strict_input_validation(): - """Strict input validation rejects lax coercion on the task path too. - - With ``strict_input_validation=True`` a lax coercion like ``{"n": "1"}`` for - an ``int`` parameter is rejected on the synchronous path. Task submission must - reject it identically rather than silently coercing and queueing it. - """ - mcp = FastMCP("strict-task-server", strict_input_validation=True) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def square(n: int) -> int: - return n * n - - async with running_task_server(mcp): - # Sync path rejects the string-for-int coercion under strict validation. - with pytest.raises(ValidationError): - await call_tool_without_optin(mcp, "square", {"n": "1"}) - # Task submission must reject it too, before any task state is created. - with pytest.raises(ValidationError): - await submit_task(mcp, "square", {"n": "1"}) - - -async def test_valid_argument_submits_under_strict_validation(): - """A well-typed argument still submits fine when strict validation is on.""" - mcp = FastMCP("strict-task-valid-server", strict_input_validation=True) - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def square(n: int) -> int: - return n * n - - async with running_task_server(mcp): - final = await run_task(mcp, "square", {"n": 4}) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": 16} - - -def test_resolve_param_hints_handles_partials(): - """Partials aren't introspectable by get_type_hints; resolve via the func. - - Argument coercion must not raise for partial-wrapped callables — it should - resolve hints for the still-unbound parameters. - """ - - async def base(prefix: str, items: list[_Item]) -> str: - return prefix - - partial_fn = functools.partial(base, "bound") - hints = _resolve_param_hints(partial_fn) - - assert hints["items"] == list[_Item] - - -# --------------------------------------------------------------------------- -# Decoration and execution -# --------------------------------------------------------------------------- - - -async def test_synchronous_tool_call_without_opt_in(): - """A tool called without a tasks opt-in executes synchronously as before.""" - mcp = _tool_server() - async with running_task_server(mcp): - result = await call_tool_without_optin(mcp, "simple_tool", {"message": "hello"}) - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "Processed: hello"} - - -async def test_tool_task_returns_metadata_immediately(): - """Submitting a task returns task metadata with a server-generated id.""" - mcp = _tool_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "simple_tool", {"message": "test"}) - assert isinstance(created, CreateTaskResult) - assert isinstance(created.task_id, str) - assert created.task_id - assert created.status == "working" - - -async def test_tool_task_executes_in_background(): - """A submitted task runs in the background and can be polled to completion.""" - mcp = FastMCP("bg-server") - mcp.add_extension(TasksExtension()) - started = asyncio.Event() - finish = asyncio.Event() - - @mcp.tool(task=True) - async def coordinated() -> str: - started.set() - await finish.wait() - return "completed" - - async with running_task_server(mcp): - created = await submit_task(mcp, "coordinated", {}) - await asyncio.wait_for(started.wait(), timeout=2.0) - working = await get_task(mcp, created.task_id) - assert working.status == "working" - finish.set() - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.result is not None - assert final.result["structuredContent"] == {"result": "completed"} - - -async def test_forbidden_tool_runs_sync_even_with_opt_in(): - """A task=False tool runs synchronously even when the caller opts into tasks.""" - mcp = _tool_server() - async with running_task_server(mcp): - result = await _opted_in_call(mcp, "sync_only_tool", {"message": "test"}) - assert not isinstance(result, CreateTaskResult) - assert result.structured_content == {"result": "Sync: test"} diff --git a/tests/tasks/server/test_task_ttl.py b/tests/tasks/server/test_task_ttl.py deleted file mode 100644 index edffda25a..000000000 --- a/tests/tasks/server/test_task_ttl.py +++ /dev/null @@ -1,125 +0,0 @@ -"""TTL handling for SEP-2663 tasks. - -Servers report `ttlMs` in the create result and in every `tasks/get` response — -while the task is working and after it completes — using Docket's default -execution TTL (900000 ms) when none is configured. -""" - -from __future__ import annotations - -import asyncio - -from fastmcp import FastMCP -from fastmcp_tasks import TasksExtension -from tests.tasks.task_helpers import ( - get_task, - running_task_server, - submit_task, - wait_for_task, -) - -# Docket's default execution_ttl is 900 seconds. -DEFAULT_TTL_MS = 900000 - - -def _ttl_server() -> FastMCP: - mcp = FastMCP("keepalive-test") - mcp.add_extension(TasksExtension()) - - @mcp.tool(task=True) - async def quick_task(value: int) -> int: - return value * 2 - - @mcp.tool(task=True) - async def slow_task() -> str: - # Never completes during the test; the test only checks status/TTL while - # the task is still working, so a suspended coroutine is enough. - await asyncio.Event().wait() - return "done" - - return mcp - - -async def test_ttl_returned_while_working(): - """ttlMs is present in the create result and in tasks/get while working.""" - mcp = _ttl_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "slow_task", {}) - assert created.ttl_ms == DEFAULT_TTL_MS - got = await get_task(mcp, created.task_id) - assert got.status == "working" - assert got.ttl_ms == DEFAULT_TTL_MS - - -async def test_ttl_returned_after_completion(): - """ttlMs is present in tasks/get after the task completes.""" - mcp = _ttl_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "quick_task", {"value": 5}) - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - assert final.ttl_ms == DEFAULT_TTL_MS - - -async def test_default_ttl_when_unspecified(): - """The server applies Docket's default TTL when none is configured.""" - mcp = _ttl_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "quick_task", {"value": 3}) - assert created.ttl_ms == DEFAULT_TTL_MS - got = await get_task(mcp, created.task_id) - assert got.ttl_ms == DEFAULT_TTL_MS - - -async def test_poll_refreshes_routing_key_ttl(): - """A poll extends the current-leg pointer's TTL (sliding expiration). - - A leg that runs longer than the pointer's wall-clock TTL would otherwise - strand `_lookup_task` on the base leg. Polling must keep the routing keys - alive: after shrinking the pointer's TTL, a `tasks/get` restores it. - """ - from fastmcp_tasks.input_store import _current_leg_key - - mcp = _ttl_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "slow_task", {}) - docket = mcp._docket - assert docket is not None - key = _current_leg_key(docket, None, created.task_id) - - async with docket.redis() as redis: - await redis.expire(key, 5) - assert await redis.ttl(key) <= 5 - - await get_task(mcp, created.task_id) - - async with docket.redis() as redis: - # Refreshed well past the shrunk 5s, back toward the full window. - assert await redis.ttl(key) > 60 - - -async def test_poll_refreshes_snapshot_ttl(): - """A poll extends the context snapshot's TTL alongside the routing keys. - - A re-entered leg restores the submitting caller from the snapshot, so an - actively polled task must never outlive it: without encryption an expired - snapshot degrades the leg to an anonymous run, and with encryption it fails - the task. After shrinking the snapshot's TTL, a `tasks/get` restores it. - """ - from fastmcp_tasks.context import _snapshot_redis_key - - mcp = _ttl_server() - async with running_task_server(mcp): - created = await submit_task(mcp, "slow_task", {}) - docket = mcp._docket - assert docket is not None - key = _snapshot_redis_key(docket, None, created.task_id) - - async with docket.redis() as redis: - await redis.expire(key, 5) - assert await redis.ttl(key) <= 5 - - await get_task(mcp, created.task_id) - - async with docket.redis() as redis: - assert await redis.ttl(key) > 60 diff --git a/tests/tasks/server/test_wire_models.py b/tests/tasks/server/test_wire_models.py deleted file mode 100644 index 111aedf49..000000000 --- a/tests/tasks/server/test_wire_models.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Validate the SEP-2663 wire models against the vendored draft JSON schema. - -The models in `fastmcp_tasks.models` serialize to the `io.modelcontextprotocol/tasks` -extension shapes. This suite validates a serialized instance of each result shape -against the corresponding `$defs` entry in the vendored draft schema -(`tests/fixtures/ext-tasks-schema-draft.json`), so wire drift is caught here. - -The vendored schema composes results as `allOf[Result, Task]` where the Task arm -carries `additionalProperties: false`; a stray `_meta` therefore fails -validation. The models omit `_meta` and the runner's `exclude_none` dump keeps it -out, which is exactly what these assertions check. - -**Known schema-vs-protocol contradiction:** the modern `tools/call` result union -carries a required `resultType` discriminator, and the SDK's client-side -`ResultClaim` requires `CreateTaskResult` to pin `resultType: "task"` — so we -emit it. The draft schema's Task arm, however, forbids `resultType` (its -`additionalProperties: false` does not list it). We validate the task *fields* -against the schema with the discriminator stripped, and assert separately that -the discriminator is present on the wire. This contradiction is reported -upstream (the schema forbids a field the base protocol requires). -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import pytest -from fastmcp_tasks.models import ( - CancelTaskResult, - CreateTaskResult, - GetTaskResult, - TaskStatus, - UpdateTaskResult, -) -from jsonschema import Draft202012Validator - -_SCHEMA = json.loads( - (Path(__file__).parents[2] / "fixtures" / "ext-tasks-schema-draft.json").read_text() -) -_DEFS = _SCHEMA["$defs"] - -_ISO = "2026-07-21T12:00:00+00:00" - - -def _validate(def_name: str, instance: dict[str, Any]) -> None: - schema = {"$defs": _DEFS, **_DEFS[def_name]} - Draft202012Validator(schema).validate(instance) - - -def _dump(model: Any) -> dict[str, Any]: - return model.model_dump(by_alias=True, mode="json", exclude_none=True) - - -def _dump_task_fields(model: Any) -> dict[str, Any]: - """Dump without the `resultType` discriminator the draft schema omits. - - `resultType` is required by the protocol's result union but forbidden by the - schema's Task arm; strip it so the remaining task fields can be validated - against the schema. `test_create_task_result_emits_result_type_discriminator` - covers the discriminator itself. - """ - dumped = _dump(model) - dumped.pop("resultType", None) - return dumped - - -def test_create_task_result_matches_schema(): - result = CreateTaskResult( - task_id="t1", - status="working", - created_at=_ISO, - last_updated_at=_ISO, - ttl_ms=900000, - poll_interval_ms=5000, - ) - _validate("CreateTaskResult", _dump_task_fields(result)) - - -def test_create_task_result_emits_result_type_discriminator(): - """The protocol requires `resultType: "task"` to distinguish a tasked result. - - The modern `tools/call` union discriminates on `resultType`, and the SDK's - `ResultClaim` for tasks pins the model to `Literal["task"]`; without it a - client cannot tell a task result from a `CallToolResult`. - """ - result = CreateTaskResult( - task_id="t1", - status="working", - created_at=_ISO, - last_updated_at=_ISO, - ttl_ms=900000, - ) - assert _dump(result)["resultType"] == "task" - - -@pytest.mark.parametrize( - ("status", "payload"), - [ - ("working", {}), - ("completed", {"result": {"content": [], "isError": False}}), - ("failed", {"error": {"code": -32603, "message": "boom"}}), - ( - "input_required", - { - "input_requests": { - "k1": {"method": "elicitation/create", "params": {"message": "?"}} - } - }, - ), - ("cancelled", {}), - ], -) -def test_get_task_result_matches_schema(status: TaskStatus, payload: dict[str, Any]): - result = GetTaskResult( - task_id="t1", - status=status, - created_at=_ISO, - last_updated_at=_ISO, - ttl_ms=900000, - poll_interval_ms=5000, - **payload, - ) - _validate("GetTaskResult", _dump_task_fields(result)) - - -def test_get_task_result_completed_omits_error_and_inputs(): - """A completed result carries only `result` (the union arm forbids the rest).""" - result = GetTaskResult( - task_id="t1", - status="completed", - created_at=_ISO, - last_updated_at=_ISO, - ttl_ms=900000, - result={"content": [], "isError": False}, - ) - dumped = _dump(result) - assert "error" not in dumped - assert "inputRequests" not in dumped - - -def test_null_ttl_is_permitted_by_schema(): - """`ttlMs` is required-but-nullable; a null TTL still validates.""" - result = CreateTaskResult( - task_id="t1", - status="working", - created_at=_ISO, - last_updated_at=_ISO, - ttl_ms=None, - ) - dumped = result.model_dump(by_alias=True, mode="json", exclude_none=False) - # Drop the other None optionals the runner would also drop, keeping ttlMs=null, - # and the resultType the draft schema omits (see module docstring). - dumped = {k: v for k, v in dumped.items() if v is not None or k == "ttlMs"} - dumped.pop("resultType", None) - _validate("CreateTaskResult", dumped) - - -@pytest.mark.parametrize("model", [UpdateTaskResult(), CancelTaskResult()]) -def test_ack_results_match_schema(model: Any): - def_name = type(model).__name__ - _validate(def_name, _dump(model)) diff --git a/tests/tasks/server/test_wire_production.py b/tests/tasks/server/test_wire_production.py deleted file mode 100644 index 14c6381c8..000000000 --- a/tests/tasks/server/test_wire_production.py +++ /dev/null @@ -1,96 +0,0 @@ -"""The server-side claim-production wrap for the tasks extension. - -`wire_production` widens the SDK's `tools/call` result serialization so a -`CreateTaskResult` (`resultType: "task"`) survives to the wire instead of being -stripped by the `CallToolResult | InputRequiredResult` surface. These tests -exercise the wrap at the exact boundary the server runner calls -(`mcp_types.methods.serialize_server_result`), which is where the SDK otherwise -drops the task fields. -""" - -from __future__ import annotations - -import mcp_types.methods as methods -import pytest - -from fastmcp_tasks import wire_production - -_MODERN = "2026-07-28" - -_TASK_DICT = { - "resultType": "task", - "taskId": "abc123", - "status": "working", - "createdAt": "2026-07-21T12:00:00+00:00", - "lastUpdatedAt": "2026-07-21T12:00:00+00:00", - "ttlMs": 900000, -} - - -@pytest.fixture -def installed(): - """Install the wrap for one test, guaranteeing removal.""" - wire_production.install() - try: - yield - finally: - wire_production.uninstall() - - -def test_without_wrap_task_fields_are_stripped(): - """Baseline: the stock serializer drops the task fields (the gap we close).""" - out = methods.serialize_server_result("tools/call", _MODERN, dict(_TASK_DICT)) - assert "taskId" not in out - - -def test_wrap_preserves_task_result(installed): - out = methods.serialize_server_result("tools/call", _MODERN, dict(_TASK_DICT)) - assert out["taskId"] == "abc123" - assert out["resultType"] == "task" - assert out["status"] == "working" - - -def test_wrap_leaves_ordinary_tool_result_untouched(installed): - """A normal (non-task) tools/call result serializes exactly as before.""" - complete = {"content": [{"type": "text", "text": "hi"}], "resultType": "complete"} - out = methods.serialize_server_result("tools/call", _MODERN, complete) - assert out["content"] == [{"type": "text", "text": "hi"}] - assert "taskId" not in out - - -def test_wrap_delegates_non_diverted_calls(installed): - """Only a task-tagged tools/call is diverted; everything else delegates. - - A `tools/list` call is never routed to task production, so its payload is - validated by the stock `ListToolsResult` surface exactly as without the - wrap — proven here by the stock validator rejecting an off-surface dict - rather than the wrap silently converting or swallowing it. - """ - from pydantic import ValidationError - - with pytest.raises(ValidationError): - methods.serialize_server_result("tools/list", _MODERN, {"tools": []}) - - -def test_uninstall_restores_stock_serializer(): - wire_production.install() - wrapped = methods.serialize_server_result - wire_production.uninstall() - assert methods.serialize_server_result is not wrapped - # And the task fields are stripped again once restored. - out = methods.serialize_server_result("tools/call", _MODERN, dict(_TASK_DICT)) - assert "taskId" not in out - - -def test_refcount_survives_nested_holds(): - """Two holds (sibling extensions): the wrap stays until the last release.""" - wire_production.install() - wire_production.install() - wire_production.uninstall() - # One hold remains; the wrap is still active. - out = methods.serialize_server_result("tools/call", _MODERN, dict(_TASK_DICT)) - assert out["taskId"] == "abc123" - wire_production.uninstall() - # Last hold released; stock behavior restored. - out = methods.serialize_server_result("tools/call", _MODERN, dict(_TASK_DICT)) - assert "taskId" not in out diff --git a/tests/tasks/task_helpers.py b/tests/tasks/task_helpers.py deleted file mode 100644 index 405996ada..000000000 --- a/tests/tasks/task_helpers.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Shared helpers for driving SEP-2663 tasks in server-side tests. - -There is no client task-submission API until Phase 4, so server-side tests drive -the task lifecycle in-process: the create decision runs through the real -`tools/call` interceptor (with a per-request tasks opt-in bound into the request -context), and `tasks/get` / `tasks/update` / `tasks/cancel` call the extension's -handler functions directly. Optional auth binding exercises the auth-scoped task -isolation. - -Typical use:: - - async with running_task_server(mcp): - created = await submit_task(mcp, "square", {"n": 6}) - final = await wait_for_task(mcp, created.task_id) - assert final.status == "completed" - -or the one-shot:: - - async with running_task_server(mcp): - final = await run_task(mcp, "square", {"n": 6}) -""" - -from __future__ import annotations - -import asyncio -import contextlib -from types import SimpleNamespace -from typing import Any, cast - -from fastmcp_tasks.handlers import tasks_cancel, tasks_get, tasks_update -from fastmcp_tasks.models import ( - CancelTaskResult, - CreateTaskResult, - GetTaskResult, - UpdateTaskResult, -) -from mcp.server.auth.middleware.auth_context import auth_context_var -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.context import ServerRequestContext -from mcp.server.session import ServerSession -from mcp_types import CLIENT_CAPABILITIES_META_KEY - -from fastmcp.server.auth import AccessToken -from fastmcp.server.dependencies import bind_request_context -from fastmcp.server.server import FastMCP -from fastmcp.utilities.tasks import TASKS_EXTENSION_ID - -TERMINAL_STATES = frozenset({"completed", "failed", "cancelled"}) - - -def opt_in_meta(settings: dict[str, Any] | None = None) -> dict[str, Any]: - """The per-request `_meta` block that opts the tasks extension in.""" - return { - CLIENT_CAPABILITIES_META_KEY: { - "extensions": {TASKS_EXTENSION_ID: settings or {}} - } - } - - -def make_access_token(client_id: str, sub: str | None = None) -> AccessToken: - """A minimal FastMCP access token for auth-scoped task tests.""" - claims: dict[str, Any] = {"sub": sub} if sub is not None else {} - return AccessToken( - token=f"token-{client_id}-{sub}", - client_id=client_id, - scopes=[], - claims=claims, - ) - - -@contextlib.contextmanager -def auth_scope(access_token: AccessToken | None): - """Bind (or clear) the auth context so `get_task_scope` sees a caller.""" - if access_token is None: - yield - return - token = auth_context_var.set(AuthenticatedUser(access_token)) - try: - yield - finally: - auth_context_var.reset(token) - - -@contextlib.contextmanager -def _opted_in_request( - name: str, arguments: dict[str, Any] | None, settings: dict[str, Any] | None -): - """Bind a request context carrying the tasks opt-in for a `tools/call`.""" - params: dict[str, Any] = { - "name": name, - "arguments": arguments or {}, - "_meta": opt_in_meta(settings), - } - srctx = ServerRequestContext( - session=cast(ServerSession, SimpleNamespace()), - lifespan_context={}, - protocol_version="2026-07-28", - method="tools/call", - params=params, - ) - with bind_request_context(srctx): - yield - - -def running_task_server(server: FastMCP): - """Enter the server lifespan (Docket backend + worker) for the block.""" - return server._lifespan_manager() - - -async def submit_task( - server: FastMCP, - name: str, - arguments: dict[str, Any] | None = None, - *, - access_token: AccessToken | None = None, - settings: dict[str, Any] | None = None, -) -> CreateTaskResult: - """Run an opted-in `tools/call` through the interceptor and return its task.""" - with auth_scope(access_token), _opted_in_request(name, arguments, settings): - result = await server.call_tool(name, arguments or {}) - if not isinstance(result, CreateTaskResult): - raise AssertionError( - f"Expected the call to be tasked, got {type(result).__name__}: {result!r}" - ) - return result - - -async def call_tool_without_optin( - server: FastMCP, - name: str, - arguments: dict[str, Any] | None = None, - *, - access_token: AccessToken | None = None, -): - """Run a `tools/call` with no tasks opt-in (synchronous unless mode=required).""" - with auth_scope(access_token): - return await server.call_tool(name, arguments or {}) - - -async def get_task( - server: FastMCP, - task_id: str, - *, - access_token: AccessToken | None = None, -) -> GetTaskResult: - """Call the `tasks/get` handler within the given auth scope.""" - with auth_scope(access_token): - return await tasks_get(server, task_id) - - -async def update_task( - server: FastMCP, - task_id: str, - input_responses: dict[str, Any], - *, - access_token: AccessToken | None = None, -) -> UpdateTaskResult: - """Call the `tasks/update` handler within the given auth scope.""" - with auth_scope(access_token): - return await tasks_update(server, task_id, input_responses) - - -async def cancel_task( - server: FastMCP, - task_id: str, - *, - access_token: AccessToken | None = None, -) -> CancelTaskResult: - """Call the `tasks/cancel` handler within the given auth scope.""" - with auth_scope(access_token): - return await tasks_cancel(server, task_id) - - -async def wait_for_task( - server: FastMCP, - task_id: str, - *, - access_token: AccessToken | None = None, - target_states: frozenset[str] = TERMINAL_STATES, - timeout: float = 5.0, - poll: float = 0.02, -) -> GetTaskResult: - """Poll `tasks/get` until the task reaches one of `target_states`.""" - deadline = asyncio.get_event_loop().time() + timeout - result = await get_task(server, task_id, access_token=access_token) - while result.status not in target_states: - if asyncio.get_event_loop().time() >= deadline: - raise TimeoutError( - f"Task {task_id} still {result.status!r} after {timeout}s " - f"(waiting for {sorted(target_states)})" - ) - await asyncio.sleep(poll) - result = await get_task(server, task_id, access_token=access_token) - return result - - -async def run_task( - server: FastMCP, - name: str, - arguments: dict[str, Any] | None = None, - *, - access_token: AccessToken | None = None, - timeout: float = 5.0, -) -> GetTaskResult: - """Submit a task and wait for it to reach a terminal state.""" - created = await submit_task(server, name, arguments, access_token=access_token) - return await wait_for_task( - server, created.task_id, access_token=access_token, timeout=timeout - ) diff --git a/tests/telemetry/test_interop.py b/tests/telemetry/test_interop.py deleted file mode 100644 index 5befb5811..000000000 --- a/tests/telemetry/test_interop.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Tests for telemetry interoperability modes. - -Validates that FastMCP's own spans can be suppressed — globally via -`telemetry_mode` or per-block via `suppress_fastmcp_telemetry()` — while trace -context propagation keeps working in `propagation_only` mode and is fully -disabled in `off` mode. -""" - -from __future__ import annotations - -import pytest -from opentelemetry import context as otel_context -from opentelemetry import trace as otel_trace -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.trace import INVALID_SPAN, SpanKind - -import fastmcp -from fastmcp import Client, Context, FastMCP -from fastmcp.client.telemetry import client_span -from fastmcp.server.telemetry import delegate_span, server_span -from fastmcp.telemetry import ( - extract_trace_context, - inject_trace_context, - native_spans_enabled, - suppress_fastmcp_telemetry, - telemetry_mode, -) - -# A well-formed W3C traceparent for extraction tests. -TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" - - -@pytest.fixture -def mode(monkeypatch: pytest.MonkeyPatch): - """Set `fastmcp.settings.telemetry_mode` for the duration of a test.""" - - def _set(value: str) -> None: - monkeypatch.setattr(fastmcp.settings, "telemetry_mode", value) - - return _set - - -def fastmcp_spans(exporter: InMemorySpanExporter) -> list[str]: - """Names of spans emitted by FastMCP's own instrumentation scope.""" - return [ - s.name - for s in exporter.get_finished_spans() - if s.instrumentation_scope is not None - and s.instrumentation_scope.name == "fastmcp" - ] - - -class TestTelemetryModeResolution: - def test_native_by_default(self): - assert telemetry_mode() == "native" - assert native_spans_enabled() - - @pytest.mark.parametrize("value", ["propagation_only", "off"]) - def test_setting_disables_native_spans(self, value: str, mode): - mode(value) - assert telemetry_mode() == value - assert not native_spans_enabled() - - def test_suppress_downgrades_native_to_propagation_only(self): - with suppress_fastmcp_telemetry(): - assert telemetry_mode() == "propagation_only" - assert not native_spans_enabled() - assert telemetry_mode() == "native" - - def test_suppress_cannot_override_off(self, mode): - """`off` means FastMCP touches nothing. A narrower request to skip - FastMCP's spans must not re-enable the propagation `off` omits.""" - mode("off") - with suppress_fastmcp_telemetry(): - assert telemetry_mode() == "off" - - def test_suppress_nests(self): - with suppress_fastmcp_telemetry(): - with suppress_fastmcp_telemetry(): - assert not native_spans_enabled() - # Outer suppression still active after the inner block exits. - assert not native_spans_enabled() - assert native_spans_enabled() - - def test_suppress_restores_on_exception(self): - with pytest.raises(RuntimeError): - with suppress_fastmcp_telemetry(): - raise RuntimeError("boom") - assert native_spans_enabled() - - -class TestSpanHelperSuppression: - """Every FastMCP span helper goes quiet when its own spans are disabled.""" - - @pytest.fixture - def helpers(self): - return { - "server": lambda: server_span( - name="test_op", - method="tools/call", - server_name="test-server", - component_type="tool", - component_key="tool://test", - ), - "client": lambda: client_span( - name="test_client", - method="tools/call", - component_key="tool://test", - ), - "delegate": lambda: delegate_span( - name="test_delegate", - provider_type="FastMCPProvider", - component_key="tool://test", - ), - } - - @pytest.mark.parametrize("helper", ["server", "client", "delegate"]) - @pytest.mark.parametrize("value", ["propagation_only", "off"]) - def test_helper_emits_nothing( - self, - helper: str, - value: str, - helpers, - mode, - trace_exporter: InMemorySpanExporter, - ): - mode(value) - with helpers[helper]() as span: - assert span is INVALID_SPAN - assert trace_exporter.get_finished_spans() == () - - @pytest.mark.parametrize("helper", ["server", "client", "delegate"]) - def test_helper_emits_nothing_under_suppress( - self, helper: str, helpers, trace_exporter: InMemorySpanExporter - ): - with suppress_fastmcp_telemetry(): - with helpers[helper]() as span: - assert span is INVALID_SPAN - assert trace_exporter.get_finished_spans() == () - - @pytest.mark.parametrize("helper", ["server", "client", "delegate"]) - def test_helper_emits_by_default( - self, helper: str, helpers, trace_exporter: InMemorySpanExporter - ): - with helpers[helper](): - pass - assert len(trace_exporter.get_finished_spans()) == 1 - - -class TestContextPropagation: - """`propagation_only` keeps trace context flowing; `off` does not.""" - - def test_extract_preserves_current_context_values(self): - """Regression: extracting the incoming traceparent must not discard - context values the caller already established. Extracting onto a fresh - root would drop FastMCP's own suppression marker (and any baggage), so - attaching the result would silently re-enable FastMCP's spans. - """ - with suppress_fastmcp_telemetry(): - parent = extract_trace_context({"traceparent": TRACEPARENT}) - token = otel_context.attach(parent) - try: - assert telemetry_mode() == "propagation_only" - finally: - otel_context.detach(token) - - def test_extract_applies_incoming_parent(self, mode): - mode("propagation_only") - parent = extract_trace_context({"traceparent": TRACEPARENT}) - token = otel_context.attach(parent) - try: - span_context = otel_trace.get_current_span().get_span_context() - assert format(span_context.trace_id, "032x") == ( - "4bf92f3577b34da6a3ce929d0e0e4736" - ) - finally: - otel_context.detach(token) - - def test_off_ignores_incoming_parent(self, mode): - """`off` is a full pass-through: the incoming context is not applied.""" - mode("off") - parent = extract_trace_context({"traceparent": TRACEPARENT}) - assert parent is otel_context.get_current() - - def test_off_does_not_inject(self, mode, trace_exporter: InMemorySpanExporter): - mode("off") - with otel_trace.get_tracer("test").start_as_current_span("root"): - assert inject_trace_context({"existing": 1}) == {"existing": 1} - - def test_propagation_only_still_injects( - self, mode, trace_exporter: InMemorySpanExporter - ): - mode("propagation_only") - with otel_trace.get_tracer("test").start_as_current_span("root"): - meta = inject_trace_context() - assert meta is not None and "traceparent" in meta - - -class TestEndToEnd: - """A real in-process client drives a real server — nothing monkeypatched - beyond the setting itself.""" - - async def test_propagation_only_parents_downstream_user_spans( - self, mode, trace_exporter: InMemorySpanExporter - ): - mode("propagation_only") - captured: dict[str, int] = {} - - server = FastMCP("interop-server") - - @server.tool - async def work(ctx: Context) -> str: - # A span the *user* creates inside their handler. - tracer = otel_trace.get_tracer("user-code") - with tracer.start_as_current_span("user-span") as span: - captured["downstream"] = span.get_span_context().trace_id - return "done" - - async with Client(server) as client: - tracer = otel_trace.get_tracer("client-code") - with tracer.start_as_current_span("client-root") as root: - captured["client"] = root.get_span_context().trace_id - await client.call_tool("work", {}) - - names = [s.name for s in trace_exporter.get_finished_spans()] - assert "user-span" in names and "client-root" in names - # FastMCP emitted none of its own spans — including the per-request - # SERVER span opened at the middleware seam, which is the whole point. - assert fastmcp_spans(trace_exporter) == [] - assert [ - s for s in trace_exporter.get_finished_spans() if s.kind == SpanKind.SERVER - ] == [] - # ...yet the user's span inherited the incoming distributed trace. - assert captured["client"] == captured["downstream"] - - async def test_propagation_only_without_incoming_trace( - self, mode, trace_exporter: InMemorySpanExporter - ): - """With no surrounding client span there is no incoming trace. The call - must still succeed and emit no FastMCP spans.""" - mode("propagation_only") - captured: dict[str, int] = {} - - server = FastMCP("interop-server") - - @server.tool - async def work(ctx: Context) -> str: - tracer = otel_trace.get_tracer("user-code") - with tracer.start_as_current_span("user-span") as span: - captured["downstream"] = span.get_span_context().trace_id - return "done" - - async with Client(server) as client: - result = await client.call_tool("work", {}) - - assert result.data == "done" - assert fastmcp_spans(trace_exporter) == [] - # A self-rooted trace was created (no incoming parent to inherit). - assert "downstream" in captured - - async def test_suppress_block_silences_a_single_call( - self, trace_exporter: InMemorySpanExporter - ): - """The scoped form suppresses one call and leaves the next instrumented.""" - server = FastMCP("interop-server") - - @server.tool - async def work() -> str: - return "done" - - async with Client(server) as client: - # Drop the spans the connection handshake already emitted. - trace_exporter.clear() - - with suppress_fastmcp_telemetry(): - await client.call_tool("work", {}) - assert fastmcp_spans(trace_exporter) == [] - - await client.call_tool("work", {}) - assert fastmcp_spans(trace_exporter) != [] diff --git a/tests/telemetry/test_span_attributes.py b/tests/telemetry/test_span_attributes.py index 554a288cb..c5cbb6279 100644 --- a/tests/telemetry/test_span_attributes.py +++ b/tests/telemetry/test_span_attributes.py @@ -3,18 +3,11 @@ from contextlib import AbstractContextManager import pytest from opentelemetry.context import Context -from opentelemetry.sdk.trace import ( - ReadableSpan, - Span, - SpanLimits, - SpanProcessor, - TracerProvider, -) +from opentelemetry.sdk.trace import Span, SpanProcessor, TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult from opentelemetry.trace import Span as APISpan -from opentelemetry.util import types as otel_types from fastmcp.client.telemetry import client_span from fastmcp.server.telemetry import delegate_span, seam_span, server_span @@ -55,87 +48,6 @@ class NonForwardingSampler(Sampler): return "NonForwardingSampler" -class RedactingSampler(Sampler): - """Forwards the attributes it receives, but replaces `mcp.method.name`. - - Mirrors a real-world sampler that deliberately alters a FastMCP attribute - (e.g. redacting the method name for privacy) rather than failing to - forward attributes at all. The restore-missing-attributes helper must - respect this decision: `mcp.method.name` is present on the span, just not - with FastMCP's original value, so it must not be overwritten. - """ - - def should_sample( - self, - parent_context: Context | None, - trace_id: int, - name: str, - kind: object = None, - attributes: otel_types.Attributes = None, - links: object = None, - trace_state: object = None, - ) -> SamplingResult: - forwarded = dict(attributes or {}) - forwarded["mcp.method.name"] = "REDACTED" - return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded) - - def get_description(self) -> str: - return "RedactingSampler" - - -class AttributeAddingSampler(Sampler): - """Forwards the attributes it receives unchanged and adds its own. - - Mirrors a sampler that annotates spans with sampling-policy metadata. - Both the sampler's own attribute and FastMCP's attributes must survive. - """ - - def should_sample( - self, - parent_context: Context | None, - trace_id: int, - name: str, - kind: object = None, - attributes: otel_types.Attributes = None, - links: object = None, - trace_state: object = None, - ) -> SamplingResult: - forwarded = dict(attributes or {}) - forwarded["sampling.policy"] = "always_on" - return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded) - - def get_description(self) -> str: - return "AttributeAddingSampler" - - -class FilteringSampler(Sampler): - """Discards every attribute it receives and substitutes its own. - - Mirrors a real-world sampler that strips component names or resource - URIs for privacy or cardinality control by returning a `SamplingResult` - with only its own attribute, ignoring what it was handed entirely. None - of FastMCP's attributes may survive on the span, and the restore helper - must not reintroduce them — that would defeat the filter. - """ - - def should_sample( - self, - parent_context: Context | None, - trace_id: int, - name: str, - kind: object = None, - attributes: otel_types.Attributes = None, - links: object = None, - trace_state: object = None, - ) -> SamplingResult: - return SamplingResult( - Decision.RECORD_AND_SAMPLE, attributes={"sampling.policy": "filtered"} - ) - - def get_description(self) -> str: - return "FilteringSampler" - - def test_known_span_attributes_are_available_on_start( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -291,179 +203,3 @@ def test_attributes_survive_a_non_forwarding_sampler( spans = [s for s in exporter.get_finished_spans() if s.name == span_name] assert len(spans) == 1 assert dict(spans[0].attributes or {}) == expected_attrs - - -@pytest.mark.parametrize( - ("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES -) -def test_redacted_attribute_survives_a_redacting_sampler( - monkeypatch: pytest.MonkeyPatch, - span_factory: Callable[[], AbstractContextManager[APISpan]], - span_name: str, - expected_attrs: dict[str, object], -) -> None: - """A sampler that deliberately replaces one of FastMCP's attributes (e.g. - redacting `mcp.method.name` for privacy) must have that decision survive. - - Restoring must only fill in attributes the sampler dropped, never - overwrite attributes the sampler kept and intentionally changed. - """ - exporter = InMemorySpanExporter() - provider = TracerProvider(sampler=RedactingSampler()) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - tracer = provider.get_tracer("test") - monkeypatch.setattr("fastmcp.client.telemetry.get_tracer", lambda: tracer) - monkeypatch.setattr("fastmcp.server.telemetry.get_tracer", lambda: tracer) - - with span_factory(): - pass - - spans = [s for s in exporter.get_finished_spans() if s.name == span_name] - assert len(spans) == 1 - attrs = dict(spans[0].attributes or {}) - - # The redaction must survive — not be clobbered by a blanket reapply. - assert attrs["mcp.method.name"] == "REDACTED" - - # Every other FastMCP attribute the sampler forwarded unchanged is - # untouched, and any it dropped are still restored. - for key, value in expected_attrs.items(): - if key == "mcp.method.name": - continue - assert attrs[key] == value - - -@pytest.mark.parametrize( - ("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES -) -def test_sampler_added_attribute_survives_alongside_fastmcp_attributes( - monkeypatch: pytest.MonkeyPatch, - span_factory: Callable[[], AbstractContextManager[APISpan]], - span_name: str, - expected_attrs: dict[str, object], -) -> None: - """A sampler that forwards attributes unchanged and adds its own must - keep both: its own attribute and every FastMCP attribute.""" - exporter = InMemorySpanExporter() - provider = TracerProvider(sampler=AttributeAddingSampler()) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - tracer = provider.get_tracer("test") - monkeypatch.setattr("fastmcp.client.telemetry.get_tracer", lambda: tracer) - monkeypatch.setattr("fastmcp.server.telemetry.get_tracer", lambda: tracer) - - with span_factory(): - pass - - spans = [s for s in exporter.get_finished_spans() if s.name == span_name] - assert len(spans) == 1 - attrs = dict(spans[0].attributes or {}) - - assert attrs["sampling.policy"] == "always_on" - for key, value in expected_attrs.items(): - assert attrs[key] == value - - -@pytest.mark.parametrize( - ("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES -) -def test_filtered_attributes_are_not_restored( - monkeypatch: pytest.MonkeyPatch, - span_factory: Callable[[], AbstractContextManager[APISpan]], - span_name: str, - expected_attrs: dict[str, object], -) -> None: - """Regression: a sampler that intentionally supplies only its own - attributes (e.g. to strip component names or resource URIs for privacy - or cardinality control) must not have FastMCP's attributes restored. - - A gate keyed off "none of our keys are present" can't tell this apart - from a bare non-forwarding sampler — both leave none of FastMCP's keys - on the span — so it would restore everything and defeat the filter. The - fix keys off the span having no attributes at all: a filtering sampler - leaves the span non-empty (its own attribute is there), which a bare - non-forwarding sampler never does. - """ - exporter = InMemorySpanExporter() - provider = TracerProvider(sampler=FilteringSampler()) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - tracer = provider.get_tracer("test") - monkeypatch.setattr("fastmcp.client.telemetry.get_tracer", lambda: tracer) - monkeypatch.setattr("fastmcp.server.telemetry.get_tracer", lambda: tracer) - - with span_factory(): - pass - - spans = [s for s in exporter.get_finished_spans() if s.name == span_name] - assert len(spans) == 1 - attrs = dict(spans[0].attributes or {}) - - assert attrs == {"sampling.policy": "filtered"} - for key in expected_attrs: - assert key not in attrs - - -@pytest.mark.parametrize( - ("span_factory", "span_name", "expected_attrs"), SPAN_HELPER_CASES -) -def test_restore_does_not_churn_sdk_attribute_limit_evictions( - monkeypatch: pytest.MonkeyPatch, - span_factory: Callable[[], AbstractContextManager[APISpan]], - span_name: str, - expected_attrs: dict[str, object], -) -> None: - """Regression: under a low `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT`, restoring - must not reinsert a key the SDK's bounded attribute map already evicted. - - An evicted key is indistinguishable from a sampler-omitted one from - inside the restore helper, so a per-key "reinsert what's missing" - strategy would cycle an evicted key back onto the span, which evicts a - *different* retained key and inflates `dropped_attributes` beyond what - the SDK's own eviction already cost. The fix gates the restore on the - span having no attributes at all (plus `dropped_attributes == 0`), so a - normal forwarding sampler colliding with a low limit is left exactly as - the SDK computed it — this test proves that by diffing against a - baseline with the restore step stubbed out entirely. - """ - monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "2") - # SpanLimits reads the env var at construction time, so build it now - # (after setting the env var) rather than relying on TracerProvider to - # pick it up implicitly. - span_limits = SpanLimits() - assert span_limits.max_span_attributes == 2 - - def run(*, stub_restore: bool) -> ReadableSpan: - # Each run gets its own MonkeyPatch context so patches from one run - # (e.g. stubbing the restore step for the baseline) don't leak into - # the other — both runs must exercise their own code path. - with pytest.MonkeyPatch.context() as mp: - exporter = InMemorySpanExporter() - provider = TracerProvider(span_limits=span_limits) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - tracer = provider.get_tracer("test") - mp.setattr("fastmcp.client.telemetry.get_tracer", lambda: tracer) - mp.setattr("fastmcp.server.telemetry.get_tracer", lambda: tracer) - if stub_restore: - mp.setattr( - "fastmcp.client.telemetry.restore_dropped_attributes", - lambda span, attrs: None, - ) - mp.setattr( - "fastmcp.server.telemetry.restore_dropped_attributes", - lambda span, attrs: None, - ) - - with span_factory(): - pass - - spans = [s for s in exporter.get_finished_spans() if s.name == span_name] - assert len(spans) == 1 - return spans[0] - - baseline = run(stub_restore=True) - # The whole test is moot if the limit didn't actually bind. - assert baseline.dropped_attributes > 0 - - with_restore = run(stub_restore=False) - - assert dict(with_restore.attributes or {}) == dict(baseline.attributes or {}) - assert with_restore.dropped_attributes == baseline.dropped_attributes diff --git a/tests/test_apps.py b/tests/test_apps.py index fe1491ed3..dac7276cc 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -418,14 +418,14 @@ class TestExtensionAdvertisement: ) async with Client(server) as client: - experimental = client.server_capabilities.experimental or {} + experimental = client.initialize_result.capabilities.experimental or {} assert experimental.get("file_exchange") == {"version": "0.3"} async def test_experimental_capabilities_default_empty(self): server = FastMCP("test") async with Client(server) as client: - experimental = client.server_capabilities.experimental + experimental = client.initialize_result.capabilities.experimental assert not experimental diff --git a/tests/test_compat.py b/tests/test_compat.py index a44991f02..bb2c1233a 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -10,7 +10,6 @@ from mcp import MCPError as SDKMCPError import fastmcp import fastmcp._compat as _compat from fastmcp import Client, FastMCP -from fastmcp import FastMCPDeprecationWarning as PublicWarning from fastmcp.client.transports import FastMCPTransport from fastmcp.exceptions import FastMCPDeprecationWarning, MCPError, McpError @@ -31,10 +30,6 @@ def _reset_warn_once() -> None: _compat.install() -def test_deprecation_warning_is_same_from_public_imports() -> None: - assert PublicWarning is FastMCPDeprecationWarning - - @pytest.fixture(autouse=True) def fresh_shims(): _reset_warn_once() @@ -56,20 +51,6 @@ class TestCamelCaseBridge: with pytest.warns(FastMCPDeprecationWarning): assert tool.outputSchema == {"type": "string"} # ty: ignore[unresolved-attribute] - @pytest.mark.parametrize( - ("camel", "snake", "value"), - [ - ("readOnlyHint", "read_only_hint", True), - ("destructiveHint", "destructive_hint", False), - ("idempotentHint", "idempotent_hint", True), - ("openWorldHint", "open_world_hint", False), - ], - ) - def test_tool_annotations_bridged(self, camel, snake, value): - annotations = mcp_types.ToolAnnotations(**{snake: value}) - with pytest.warns(FastMCPDeprecationWarning): - assert getattr(annotations, camel) is value - def test_call_tool_result_is_error_bridged(self): result = mcp_types.CallToolResult(content=[], is_error=True) with pytest.warns(FastMCPDeprecationWarning): @@ -250,8 +231,7 @@ class TestClientBehaviorCompat: assert result.data == "hi" async def test_ping_returns_bool(self, server): - # `ping` only exists on the older protocol, so this pins that era. - client = Client(transport=FastMCPTransport(server), mode="legacy") + client = Client(transport=FastMCPTransport(server)) async with client: result = await client.ping() assert result is True diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 042274212..d85ea0322 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -5,7 +5,6 @@ from __future__ import annotations import pytest from mcp import MCPError from mcp_types import INTERNAL_ERROR, INVALID_PARAMS -from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY from fastmcp import Client, FastMCP from fastmcp.exceptions import ( @@ -72,20 +71,6 @@ class TestWireErrorCodes: assert exc_info.value.error.code == INVALID_PARAMS assert "Resource not found" in exc_info.value.error.message - async def test_resource_not_found_echoes_uri_in_data(self): - """SEP-2164 SHOULD: the error names which URI was missing. - - A client that pipelined several reads cannot otherwise tell which one - failed from the message alone. - """ - mcp = FastMCP("test-server") - - async with Client(mcp) as client: - with pytest.raises(MCPError) as exc_info: - await client.read_resource_mcp("config://missing") - - assert exc_info.value.error.data == {"uri": "config://missing"} - async def test_prompt_not_found_uses_invalid_params(self): mcp = FastMCP("test-server") @@ -95,45 +80,3 @@ class TestWireErrorCodes: assert exc_info.value.error.code == INVALID_PARAMS assert "Unknown prompt" in exc_info.value.error.message - - -class TestMissingClientCapabilityFromTool: - """A tool's `-32021` must reach the wire, not become an `isError` result. - - SEP-2575 makes this error a statement about the *request* — the server - cannot service it at all — so flattening it into a tool result would drop - the code and tell the client the call succeeded. Every other `MCPError` - raised under a tool still masks into a result, since those describe how the - call went rather than whether it could run. - """ - - @staticmethod - def _server() -> FastMCP: - mcp = FastMCP("capability-test") - - @mcp.tool - async def needs_sampling() -> str: - raise MCPError( - code=MISSING_REQUIRED_CLIENT_CAPABILITY, - message="Client did not declare the required 'sampling' capability", - data={"requiredCapabilities": {"sampling": {}}}, - ) - - @mcp.tool - async def upstream_failed() -> str: - raise MCPError(code=INTERNAL_ERROR, message="upstream exploded") - - return mcp - - async def test_capability_error_propagates_as_protocol_error(self): - async with Client(self._server()) as client: - with pytest.raises(MCPError) as exc_info: - await client.call_tool("needs_sampling") - - assert exc_info.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY - assert exc_info.value.error.data == {"requiredCapabilities": {"sampling": {}}} - - async def test_other_mcp_errors_still_become_tool_errors(self): - async with Client(self._server()) as client: - with pytest.raises(ToolError): - await client.call_tool("upstream_failed") diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py index 9737454ff..86f9eae60 100644 --- a/tests/test_fastmcp_app.py +++ b/tests/test_fastmcp_app.py @@ -22,7 +22,6 @@ from fastmcp.apps.app import ( FastMCPApp, _make_resolver, ) -from fastmcp.server.providers.addressing import hash_tool, hashed_backend_name from fastmcp.tools.base import Tool # --------------------------------------------------------------------------- @@ -580,13 +579,13 @@ class TestCallToolAppRouting: # --------------------------------------------------------------------------- -# App-only tool visibility: declared in meta, listed on the wire +# App-only tool filtering from server list_tools / get_tool # --------------------------------------------------------------------------- -class TestAppOnlyToolVisibility: - async def test_app_only_tool_appears_in_list_tools(self): - """@app.tool() (visibility=["app"]) is listed; the host filters it out.""" +class TestAppOnlyToolFiltering: + async def test_app_only_tool_hidden_from_list_tools(self): + """@app.tool() (visibility=["app"]) should not appear in server.list_tools().""" app = FastMCPApp("crm") @app.tool() @@ -598,40 +597,7 @@ class TestAppOnlyToolVisibility: tools = await server.list_tools() names = [t.name for t in tools] - assert "save_contact" in names - - async def test_app_only_tool_declares_app_visibility(self): - """The listed tool carries visibility=["app"] so a host can filter it.""" - app = FastMCPApp("crm") - - @app.tool() - def save_contact(name: str) -> str: - return name - - server = FastMCP("Platform") - server.add_provider(app) - - tool = next(t for t in await server.list_tools() if t.name == "save_contact") - assert tool.meta is not None - assert tool.meta["ui"]["visibility"] == ["app"] - - async def test_app_only_tool_visibility_survives_the_wire(self): - """A client sees the visibility declaration, which is what it filters on.""" - app = FastMCPApp("crm") - - @app.tool() - def save_contact(name: str) -> str: - return name - - server = FastMCP("Platform") - server.add_provider(app) - - async with Client(server) as client: - tool = next( - t for t in await client.list_tools() if t.name == "save_contact" - ) - assert tool.meta is not None - assert tool.meta["ui"]["visibility"] == ["app"] + assert "save_contact" not in names async def test_model_visible_tool_in_list_tools(self): """@app.tool(model=True) (visibility=["app","model"]) appears in list_tools.""" @@ -663,8 +629,8 @@ class TestAppOnlyToolVisibility: names = [t.name for t in tools] assert "show_dashboard" in names - async def test_app_only_tool_callable_via_hashed_address(self): - """The hashed address still resolves, independent of the display name.""" + async def test_app_only_tool_still_callable_via_app_name(self): + """Even though filtered from list_tools, app-only tools are callable via call_tool with app_name.""" app = FastMCPApp("contacts") @app.tool() @@ -674,30 +640,35 @@ class TestAppOnlyToolVisibility: server = FastMCP("Platform") server.add_provider(app) + # Verify it's hidden from list_tools + tools = await server.list_tools() + names = [t.name for t in tools] + assert "save" not in names + + # But still callable via the hashed-address routing path. + from fastmcp.server.providers.addressing import hashed_backend_name + result = await server.call_tool( hashed_backend_name("contacts", "save"), {"name": "alice"} ) assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - async def test_app_only_tool_callable_by_display_name(self): - """App-only tools resolve normally; the host decides who may call them.""" - app = FastMCPApp("contacts") + async def test_app_only_tool_hidden_from_get_tool(self): + """server.get_tool() returns None for app-only tools.""" + app = FastMCPApp("crm") @app.tool() - def save(name: str) -> str: - return f"saved {name}" + def save_contact(name: str) -> str: + return name server = FastMCP("Platform") server.add_provider(app) - tool = await server.get_tool("save") - assert tool is not None + tool = await server.get_tool("save_contact") + assert tool is None - result = await server.call_tool("save", {"name": "alice"}) - assert result.content[0].text == "saved alice" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - async def test_app_only_tool_namespaced_in_list_tools(self): - """Namespacing renames app-only tools like any other tool.""" + async def test_app_only_tool_hidden_with_namespace(self): + """App-only tools hidden even when accessed through a namespace.""" app = FastMCPApp("crm") @app.tool() @@ -709,23 +680,7 @@ class TestAppOnlyToolVisibility: tools = await server.list_tools() names = [t.name for t in tools] - assert "crm_save" in names - - async def test_app_only_tool_carries_public_hash(self): - """The identity hash is public meta, so intermediaries can match on it.""" - app = FastMCPApp("crm") - - @app.tool() - def save(name: str) -> str: - return name - - server = FastMCP("Platform") - server.add_provider(app, namespace="crm") - - async with Client(server) as client: - tool = next(t for t in await client.list_tools() if t.name == "crm_save") - assert tool.meta is not None - assert tool.meta["fastmcp"]["tool_hash"] == hash_tool("crm", "save") + assert "crm_save" not in names # --------------------------------------------------------------------------- @@ -917,25 +872,21 @@ class TestAppIntegration: server = FastMCP("Platform") server.add_provider(app, namespace="crm") - # Both tools are listed (namespaced). The backend tool declares - # visibility=["app"] so the host keeps it out of the model's list. + # The @app.ui() tool should be visible (namespaced) to the client. + # The @app.tool() backend tool should NOT appear. async with Client(server) as client: tools = await client.list_tools() tool_names = [t.name for t in tools] assert "crm_contact_form" in tool_names - assert "crm_save_contact" in tool_names - - backend = next(t for t in tools if t.name == "crm_save_contact") - assert backend.meta is not None - assert backend.meta["ui"]["visibility"] == ["app"] + assert "crm_save_contact" not in tool_names # Call the UI tool through the client and check structured_content result = await client.call_tool_mcp("crm_contact_form", {}) sc = result.structured_content assert sc is not None - # Call the backend tool via its hashed address — resolves regardless - # of the namespace transform applied to the display name. + # Call the backend tool via its hashed address — bypasses namespace + # transforms and visibility filtering by going through the registry. backend_result = await server.call_tool( hashed_backend_name("contacts", "save_contact"), {"name": "Alice", "email": "alice@example.com"}, diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index 93d673e61..a33208ca1 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -15,15 +15,13 @@ from unittest.mock import AsyncMock, patch import psutil import pytest from mcp_types import TextContent -from pydantic import ConfigDict -from fastmcp import Context, FastMCP +from fastmcp import FastMCP from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.auth.oauth import OAuthClientProvider from fastmcp.client.client import Client from fastmcp.client.logging import LogMessage from fastmcp.client.transports import ( - FastMCPTransport, MCPConfigTransport, SSETransport, StdioTransport, @@ -38,27 +36,19 @@ from fastmcp.mcp_config import ( StdioMCPServer, TransformingStdioMCPServer, ) -from fastmcp.server.elicitation import AcceptedElicitation from fastmcp.tools.base import Tool as FastMCPTool -# Some tests in this module spawn subprocess servers via stdio, each paying a -# full interpreter startup plus `import fastmcp` (~0.7s). They take 3-6s idle, -# but on a loaded CI runner with four xdist workers competing they have blown a -# 15s ceiling. The timeout is here to catch a genuine hang, not to police speed, -# so give the module room rather than tuning each test individually. +# These tests spawn subprocess servers via stdio which can be slow under +# parallel CI load. Give them more headroom than the 5s default, and skip +# entirely on Windows due to process lifecycle issues. pytestmark = [ - pytest.mark.timeout(60), + pytest.mark.timeout(15), + pytest.mark.skipif( + sys.platform.startswith("win32"), + reason="Windows has process lifecycle issues with stdio subprocesses", + ), ] -# Most tests below run entirely in-memory (via InMemoryStdioMCPServer) or -# only parse/serialize config objects, so they're safe on Windows. Apply this -# marker only to tests that spawn a real subprocess (or attempt to, e.g. via -# a nonexistent command) — those still hit Windows process lifecycle issues. -requires_subprocess = pytest.mark.skipif( - sys.platform.startswith("win32"), - reason="Windows has process lifecycle issues with stdio subprocesses", -) - def running_under_debugger(): return os.environ.get("DEBUGPY_RUNNING") == "true" @@ -73,91 +63,6 @@ def gc_collect_harder(): gc.collect() -class InMemoryStdioMCPServer(StdioMCPServer): - """Test double for a plain (non-transforming) `StdioMCPServer` that skips - subprocess spawning in favor of an in-memory transport. - - `MCPConfigTransport`'s composite path calls `server_config.to_transport()` - polymorphically for any *non-transforming* server entry (see - `_create_proxy` in `fastmcp.client.transports.config`), so overriding - `to_transport()` on a subclass is enough to swap in an in-memory backend - while still exercising the real MCPConfig/MCPConfigTransport composition - code: proxy creation, namespace-prefixed mounting, log/elicitation - forwarding, and session handling. - - This does NOT work for `TransformingStdioMCPServer` configs (tool - transforms / tag filters): `_create_proxy` calls the *unbound* - `StdioMCPServer.to_transport` for those, bypassing any subclass override, - so transform/tag-filter tests still need a real subprocess. - """ - - model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) - - mcp: FastMCP - command: str = "in-memory" - - def to_transport(self) -> FastMCPTransport: - return FastMCPTransport(mcp=self.mcp) - - -class TestConfigTransportLegacyOnly: - """`MCPConfigTransport.legacy_only` gating (regression for the over-broad flag). - - A single-server config delegates directly to the underlying transport with no - proxy, so it must mirror that transport's era capability rather than being - forced legacy. Only the multi-server composite (backed by legacy-era - ProxyClients) is legacy-only. - """ - - def test_single_modern_capable_server_is_not_forced_legacy(self): - """A single Streamable HTTP backend stays modern-capable under mode='auto'.""" - config = { - "mcpServers": {"only": {"url": "https://example.com/mcp"}}, - } - transport = MCPConfigTransport(config) - assert isinstance(transport.transport, StreamableHttpTransport) - assert transport.legacy_only is False - - def test_single_sse_server_mirrors_legacy_only(self): - """A single SSE backend is legacy-only because SSE cannot serve modern.""" - config = { - "mcpServers": { - "only": {"url": "https://example.com/sse", "transport": "sse"} - }, - } - transport = MCPConfigTransport(config) - assert isinstance(transport.transport, SSETransport) - assert transport.legacy_only is True - - def test_multi_server_config_is_legacy_only(self): - """A multi-server composite is legacy-only regardless of backend eras.""" - config = { - "mcpServers": { - "a": {"url": "https://a.example.com/mcp"}, - "b": {"url": "https://b.example.com/mcp"}, - }, - } - transport = MCPConfigTransport(config) - assert transport.legacy_only is True - - def test_transforming_single_server_wrapper_is_legacy_only(self): - """A single-server config that uses tool transforms or tag filters wraps - a legacy-pinned proxy; the wrapper transport must advertise legacy-only - so a default `mode="auto"` frontend negotiates the same era as the - backend rather than negotiating modern against a legacy upstream.""" - config = { - "mcpServers": { - "a": { - "url": "https://a.example.com/mcp", - "include_tags": ["public"], - }, - }, - } - mcp_config = MCPConfig.from_dict(config) - transport = mcp_config.mcpServers["a"].to_transport() - assert transport.legacy_only is True - - def test_parse_single_stdio_config(): config = { "mcpServers": { @@ -405,23 +310,35 @@ def test_parse_multiple_servers(): assert mcp_config.mcpServers["test_server_2"].env == {"TEST": "test"} -def _make_add_server() -> FastMCP: - app = FastMCP() +async def test_multi_client(tmp_path: Path): + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP - @app.tool - def add(a: int, b: int) -> int: - return a + b + mcp = FastMCP() - return app + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + if __name__ == '__main__': + mcp.run() + """) -async def test_multi_client(): - config = MCPConfig( - mcpServers={ - "test_1": InMemoryStdioMCPServer(mcp=_make_add_server()), - "test_2": InMemoryStdioMCPServer(mcp=_make_add_server()), + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "test_1": { + "command": "python", + "args": [str(script_path)], + }, + "test_2": { + "command": "python", + "args": [str(script_path)], + }, } - ) + } client = Client(config) @@ -435,13 +352,35 @@ async def test_multi_client(): assert result_2.data == 3 -async def test_multi_client_parallel_calls(): - config = MCPConfig( - mcpServers={ - "test_1": InMemoryStdioMCPServer(mcp=_make_add_server()), - "test_2": InMemoryStdioMCPServer(mcp=_make_add_server()), +async def test_multi_client_parallel_calls(tmp_path: Path): + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "test_1": { + "command": "python", + "args": [str(script_path)], + }, + "test_2": { + "command": "python", + "args": [str(script_path)], + }, } - ) + } client = Client(config) @@ -466,13 +405,12 @@ async def _wait_for_process_exit(pid: int, timeout: float = 3.0) -> None: psutil.Process(pid) except psutil.NoSuchProcess: return - await asyncio.sleep(0.005) + await asyncio.sleep(0.05) # Final check — if still alive, let the NoSuchProcess propagation fail the test clearly psutil.Process(pid) pytest.fail(f"Process {pid} still alive after {timeout}s") -@requires_subprocess @pytest.mark.skipif( running_under_debugger(), reason="Debugger holds a reference to the transport", @@ -533,7 +471,6 @@ async def test_multi_client_lifespan(tmp_path: Path): await _wait_for_process_exit(pid_2) -@requires_subprocess @pytest.mark.timeout(15) async def test_multi_client_force_close(tmp_path: Path): server_script = inspect.cleandoc(""" @@ -637,29 +574,41 @@ async def test_remote_config_with_oauth_literal(): assert isinstance(client.transport.transport.auth, OAuthClientProvider) -def _make_log_server() -> FastMCP: - app = FastMCP() - - @app.tool - async def log_test(message: str, ctx: Context) -> int: - await ctx.log(message) - return 42 - - return app - - -async def test_multi_client_with_logging(caplog): +async def test_multi_client_with_logging(tmp_path: Path, caplog): """ Tests that logging is properly forwarded to the ultimate client. """ caplog.set_level(logging.INFO, logger=__name__) - config = MCPConfig( - mcpServers={ - "test_server": InMemoryStdioMCPServer(mcp=_make_log_server()), - "test_server_2": InMemoryStdioMCPServer(mcp=_make_log_server()), + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP, Context + + mcp = FastMCP() + + @mcp.tool + async def log_test(message: str, ctx: Context) -> int: + await ctx.log(message) + return 42 + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "test_server": { + "command": "python", + "args": [str(script_path)], + }, + "test_server_2": { + "command": "python", + "args": [str(script_path)], + }, } - ) + } MESSAGES = [] @@ -692,7 +641,6 @@ async def test_multi_client_with_logging(caplog): assert test_records[0].msg == "test 42" -@requires_subprocess async def test_multi_client_with_transforms(tmp_path: Path): """ Tests that transforms are properly applied to the tools. @@ -749,7 +697,6 @@ async def test_multi_client_with_transforms(tmp_path: Path): assert result.data == 3 -@requires_subprocess async def test_canonical_multi_client_with_transforms(tmp_path: Path): """Test that transforms are not applied to servers in a canonical MCPConfig.""" server_script = inspect.cleandoc(""" @@ -801,7 +748,6 @@ async def test_canonical_multi_client_with_transforms(tmp_path: Path): assert "test_1_transformed_add" not in tools_by_name -@requires_subprocess @pytest.mark.flaky(retries=3) async def test_multi_client_transform_with_filtering(tmp_path: Path): """ @@ -864,7 +810,6 @@ async def test_multi_client_transform_with_filtering(tmp_path: Path): assert "test_2_subtract" in tools_by_name -@requires_subprocess @pytest.mark.flaky(retries=3) async def test_single_server_config_include_tags_filtering(tmp_path: Path): """include_tags should filter tools even with a single server in the config.""" @@ -907,29 +852,39 @@ async def test_single_server_config_include_tags_filtering(tmp_path: Path): assert "subtract" not in tool_names -def _make_elicit_server() -> FastMCP: - app = FastMCP() - - @app.tool - async def elicit_test(ctx: Context) -> int: - result = await ctx.elicit("Pick a number", response_type=int) - assert isinstance(result, AcceptedElicitation) - assert isinstance(result.data, int) - return result.data - - return app - - -async def test_multi_client_with_elicitation(): +async def test_multi_client_with_elicitation(tmp_path: Path): """ Tests that elicitation is properly forwarded to the ultimate client. """ - config = MCPConfig( - mcpServers={ - "test_server": InMemoryStdioMCPServer(mcp=_make_elicit_server()), - "test_server_2": InMemoryStdioMCPServer(mcp=_make_elicit_server()), + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP, Context + + mcp = FastMCP() + + @mcp.tool + async def elicit_test(ctx: Context) -> int: + result = await ctx.elicit('Pick a number', response_type=int) + return result.data + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "test_server": { + "command": "python", + "args": [str(script_path)], + }, + "test_server_2": { + "command": "python", + "args": [str(script_path)], + }, } - ) + } async def elicitation_handler(message, response_type, params, ctx): return response_type(value=42) @@ -939,29 +894,41 @@ async def test_multi_client_with_elicitation(): assert result.data == 42 -def _make_greet_server() -> FastMCP: - app = FastMCP() - - @app.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - return app - - -async def test_multi_server_config_transport(): +async def test_multi_server_config_transport(tmp_path: Path): """ Tests that MCPConfigTransport properly handles multi-server configurations. Related to https://github.com/PrefectHQ/fastmcp/issues/2802 - verifies the refactored architecture creates composite servers correctly. """ - config = MCPConfig( - mcpServers={ - "server1": InMemoryStdioMCPServer(mcp=_make_greet_server()), - "server2": InMemoryStdioMCPServer(mcp=_make_greet_server()), + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def greet(name: str) -> str: + return f"Hello, {name}!" + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "greet_server.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "server1": { + "command": "python", + "args": [str(script_path)], + }, + "server2": { + "command": "python", + "args": [str(script_path)], + }, } - ) + } # Create client with multiple servers client = Client(config) @@ -1026,29 +993,41 @@ async def test_multi_server_timeout_propagation(): ) -def _make_session_server() -> FastMCP: - app = FastMCP() - - @app.tool - def get_session(ctx: Context) -> str: - return ctx.session_id - - return app - - -async def test_multi_server_session_persistence(): +async def test_multi_server_session_persistence(tmp_path: Path): """Test that session IDs persist across tool calls in multi-server mode. Regression test for https://github.com/PrefectHQ/fastmcp/issues/2790 — MCPConfigTransport was not connecting ProxyClients before mounting, so each tool call opened a new session with the backend server. """ - config = MCPConfig( - mcpServers={ - "server1": InMemoryStdioMCPServer(mcp=_make_session_server()), - "server2": InMemoryStdioMCPServer(mcp=_make_session_server()), + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP, Context + + mcp = FastMCP() + + @mcp.tool + def get_session(ctx: Context) -> str: + return ctx.session_id + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "session_server.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "server1": { + "command": "python", + "args": [str(script_path)], + }, + "server2": { + "command": "python", + "args": [str(script_path)], + }, } - ) + } client = Client(config) async with client: @@ -1083,7 +1062,6 @@ async def test_single_server_config_transport(): assert len(transport._transports) == 1 -@requires_subprocess @pytest.mark.parametrize( "server_order", [ @@ -1092,19 +1070,38 @@ async def test_single_server_config_transport(): ], ids=["good_first", "bad_first"], ) -async def test_multi_server_partial_failure(server_order: dict): +async def test_multi_server_partial_failure(tmp_path: Path, server_order: dict): """When one server fails to connect, the others should still work.""" - servers: dict[str, MCPServerTypes] = {} + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + servers = {} for name, is_good in server_order.items(): if is_good: - servers[name] = InMemoryStdioMCPServer(mcp=_make_add_server()) + servers[name] = { + "command": "python", + "args": [str(script_path)], + } else: - servers[name] = StdioMCPServer( - command="this-command-does-not-exist-anywhere", - args=[], - ) + servers[name] = { + "command": "this-command-does-not-exist-anywhere", + "args": [], + } - client = Client(MCPConfig(mcpServers=servers)) + client = Client({"mcpServers": servers}) async with client: tools = await client.list_tools() tool_names = [t.name for t in tools] @@ -1112,18 +1109,36 @@ async def test_multi_server_partial_failure(server_order: dict): assert len(tools) == 1 -@requires_subprocess -async def test_multi_server_partial_failure_logs_warning(caplog): +async def test_multi_server_partial_failure_logs_warning(tmp_path: Path, caplog): """A warning should be logged when a server fails to connect.""" - config = MCPConfig( - mcpServers={ - "good_server": InMemoryStdioMCPServer(mcp=_make_add_server()), - "bad_server": StdioMCPServer( - command="this-command-does-not-exist-anywhere", - args=[], - ), + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "good_server": { + "command": "python", + "args": [str(script_path)], + }, + "bad_server": { + "command": "this-command-does-not-exist-anywhere", + "args": [], + }, } - ) + } with caplog.at_level(logging.WARNING): async with Client(config): @@ -1137,7 +1152,6 @@ async def test_multi_server_partial_failure_logs_warning(caplog): assert len(warning_records) == 1 -@requires_subprocess async def test_multi_server_all_fail(): """When all servers fail to connect, a ConnectionError should be raised.""" config = MCPConfig( @@ -1159,28 +1173,36 @@ async def test_multi_server_all_fail(): pass -def _make_ping_server() -> FastMCP: - app = FastMCP() - - @app.tool - def ping() -> str: - return "pong" - - return app - - -@requires_subprocess -async def test_multi_server_partial_failure_cleanup(): +async def test_multi_server_partial_failure_cleanup(tmp_path: Path): """Transports for failed servers should not leak into _transports.""" - config = MCPConfig( - mcpServers={ - "working": InMemoryStdioMCPServer(mcp=_make_ping_server()), - "broken": StdioMCPServer( - command="this-command-does-not-exist-anywhere", - args=[], - ), + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def ping() -> str: + return "pong" + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "working": { + "command": "python", + "args": [str(script_path)], + }, + "broken": { + "command": "this-command-does-not-exist-anywhere", + "args": [], + }, } - ) + } transport = MCPConfigTransport(config) async with transport.connect_session(): diff --git a/tests/test_no_legacy_httpx.py b/tests/test_no_legacy_httpx.py index 24ee01366..e4cfb8c6e 100644 --- a/tests/test_no_legacy_httpx.py +++ b/tests/test_no_legacy_httpx.py @@ -6,17 +6,17 @@ masks clean-install regressions: an accidental ``import httpx`` (directly or via a third-party integration such as authlib's httpx client) passes CI but breaks any install without those extras. -These tests simulate a clean install by blocking legacy httpx imports at the -meta-path level and verify that ordinary server startup leaves both legacy -packages unloaded. +This test simulates the clean install by running a subprocess that blocks +legacy httpx imports at the meta-path level, then imports the modules that +have historically regressed. The defensive user-compat shim in +``fastmcp.server.server`` catches ImportError by design and must keep working +when httpx is absent. """ import subprocess import sys import textwrap -import pytest - _BLOCKER_SCRIPT = textwrap.dedent( """ import sys @@ -43,30 +43,7 @@ _BLOCKER_SCRIPT = textwrap.dedent( """ ) -_STARTUP_SCRIPT = textwrap.dedent( - """ - import sys - from fastmcp import FastMCP - - server = FastMCP("Legacy httpx import guard") - app = server.http_app(transport="http", stateless_http=True) - assert app is not None - - loaded = [ - name - for name in sys.modules - if name == "httpx" - or name.startswith("httpx.") - or name == "httpcore" - or name.startswith("httpcore.") - ] - assert not loaded, loaded - """ -) - - -@pytest.mark.subprocess_heavy def test_fastmcp_imports_without_legacy_httpx(): result = subprocess.run( [sys.executable, "-c", _BLOCKER_SCRIPT], @@ -78,14 +55,3 @@ def test_fastmcp_imports_without_legacy_httpx(): f"Import failed with legacy httpx blocked:\n{result.stderr}" ) assert "OK" in result.stdout - - -@pytest.mark.subprocess_heavy -def test_default_http_app_does_not_load_legacy_httpx(): - result = subprocess.run( - [sys.executable, "-c", _STARTUP_SCRIPT], - capture_output=True, - text=True, - timeout=30, - ) - assert result.returncode == 0, result.stderr diff --git a/tests/test_settings.py b/tests/test_settings.py index 09d014a66..3052a0d35 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,6 +1,42 @@ import pytest +from fastmcp import settings from fastmcp.settings import Settings +from fastmcp.utilities.tests import temporary_settings + + +def test_get_setting_reads_nested_values(): + test_settings = Settings() + + assert test_settings.get_setting("docket__name") == "fastmcp" + assert test_settings.get_setting("docket__redelivery_timeout__seconds") == 300 + + +def test_set_setting_updates_nested_values(): + test_settings = Settings() + + test_settings.set_setting("docket__name", "worker-queue") + + assert test_settings.docket.name == "worker-queue" + assert test_settings.get_setting("docket__name") == "worker-queue" + + +def test_temporary_settings_restores_nested_values(): + original_name = settings.get_setting("docket__name") + + with temporary_settings(docket__name="temporary-queue"): + assert settings.get_setting("docket__name") == "temporary-queue" + + assert settings.get_setting("docket__name") == original_name + + +def test_get_setting_raises_for_missing_nested_parent(): + test_settings = Settings() + + with pytest.raises(AttributeError) as exc_info: + test_settings.get_setting("docket__missing__value") + + assert str(exc_info.value) == "Setting missing does not exist." def test_http_host_origin_protection_defaults_to_false(): diff --git a/tests/test_upgrade_from_v3.py b/tests/test_upgrade_from_v3.py deleted file mode 100644 index 2f076d055..000000000 --- a/tests/test_upgrade_from_v3.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Upgrade-reality tests: does a FastMCP 3.x server survive the move to v4? - -These tests are the executable half of the `docs/getting-started/upgrading/from-fastmcp-3` -guide. They fall into three groups: - -- `TestCommonServersUpgradeCleanly` builds servers the way the 3.x docs taught - and runs them end-to-end under v4 defaults. These are the "nothing to do" - cases — a typical server upgrades untouched. -- `TestRemovedSurfacesFailLoudly` pins every hard removal to the exact error a - user hits, so the break is a clear signal rather than silent misbehavior. - Each case names its 4.0 replacement in a comment. -- `TestBehaviorChanges` covers the shifts that compile fine but behave - differently: the `mode="auto"` client default, path-traversal screening, and - the resource-not-found error code. - -The camelCase field bridge and the `McpError` alias are covered in -`test_compat.py`; this file deliberately does not repeat them. -""" - -import importlib -import inspect - -import pytest - -# Protocol types now live in mcp_types directly; fastmcp.types no longer -# re-exports them (it holds only FastMCP-defined types like Textarea). -from mcp_types import ErrorData, TextContent, Tool, ToolAnnotations - -from fastmcp import Client, FastMCP, settings - -# The canonical replacement symbols the upgrade guide points users to. Importing -# them here — the ordinary in-process path every other test in the suite uses — -# means this file fails at collection if the guide ever names a symbol that no -# longer resolves. `create_proxy`, `settings`, `McpError`, and -# `CacheableToolResult` above are part of the same set. -from fastmcp.apps import AppConfig -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler -from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.dependencies import Depends -from fastmcp.exceptions import McpError -from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.resources.function_resource import FunctionResource -from fastmcp.server import create_proxy -from fastmcp.server.auth import ( - AuthCheck, - AuthContext, - require_roles, - require_scopes, - restrict_tag, - run_auth_checks, -) -from fastmcp.server.middleware.caching import CacheableToolResult -from fastmcp.server.providers.openapi import OpenAPIProvider -from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient -from fastmcp.server.transforms import PromptsAsTools, ResourcesAsTools, ToolTransform -from fastmcp.tools.function_tool import FunctionTool - -# The two authorization names the removed shim exported that `fastmcp.server.auth` -# deliberately does not re-export (middleware plumbing, no documented user-facing -# use). The upgrade guide sends them here instead, so pin that path too. -from fastmcp.utilities.authorization import ( - run_auth_checks_with_shortfall, - scope_requirements, -) - - -class TestCommonServersUpgradeCleanly: - """Servers written against the 3.x API run unchanged on v4 defaults.""" - - async def test_basic_tool_resource_prompt_server(self): - mcp = FastMCP("Demo", instructions="A demo server") - - @mcp.tool - def add(a: int, b: int) -> int: - return a + b - - @mcp.resource("data://config") - def config() -> dict: - return {"version": "1.0"} - - @mcp.prompt - def greet(who: str) -> str: - return f"Hello, {who}" - - async with Client(mcp) as client: # default mode="auto" - tools = await client.list_tools() - resources = await client.list_resources() - prompts = await client.list_prompts() - result = await client.call_tool("add", {"a": 2, "b": 3}) - - assert {t.name for t in tools} == {"add"} - assert {str(r.uri) for r in resources} == {"data://config"} - assert {p.name for p in prompts} == {"greet"} - assert result.data == 5 - - async def test_templated_resource_server(self): - mcp = FastMCP("Templated") - - @mcp.resource("files://{name}") - def get_file(name: str) -> str: - return f"contents of {name}" - - async with Client(mcp) as client: - contents = await client.read_resource("files://report.txt") - - assert contents[0].text == "contents of report.txt" - - async def test_mounted_server(self): - parent = FastMCP("Parent") - child = FastMCP("Child") - - @child.tool - def ping() -> str: - return "pong" - - parent.mount(child, namespace="child") - - async with Client(parent) as client: - tools = await client.list_tools() - result = await client.call_tool("child_ping", {}) - - assert "child_ping" in {t.name for t in tools} - assert result.data == "pong" - - async def test_proxy_server(self): - backend = FastMCP("Backend") - - @backend.tool - def ping() -> str: - return "pong" - - proxy = create_proxy(backend) - - async with Client(proxy) as client: - tools = await client.list_tools() - result = await client.call_tool("ping", {}) - - assert "ping" in {t.name for t in tools} - assert result.data == "pong" - - -# --- Canonical replacement surfaces the guide points users to --- - - -class TestCanonicalReplacementsResolve: - def test_replacement_symbols_are_bound(self): - # The imports at the top of this module already prove these resolve - # (a broken pointer would fail collection). This asserts each is bound - # so the guarantee is an explicit, named test rather than a side effect. - symbols = ( - FunctionTool, - FunctionResource, - FunctionPrompt, - OpenAPIProvider, - FastMCPProxy, - ProxyClient, - create_proxy, - AppConfig, - ToolTransform, - PromptsAsTools, - ResourcesAsTools, - Depends, - McpError, - CacheableToolResult, - TextContent, - Tool, - ToolAnnotations, - ErrorData, - ) - assert all(sym is not None for sym in symbols) - - def test_authorization_symbols_resolve_from_their_documented_paths(self): - # The removed `fastmcp.server.auth.authorization` shim exported eight - # names, and the upgrade guide splits them across two replacements. Pin - # both halves: the checks users write against reach the auth package, - # while the two middleware helpers stay on the utilities module. - from_auth_package = ( - AuthCheck, - AuthContext, - require_roles, - require_scopes, - restrict_tag, - run_auth_checks, - ) - from_utilities = (run_auth_checks_with_shortfall, scope_requirements) - assert all(sym is not None for sym in from_auth_package + from_utilities) - - import fastmcp.server.auth as auth_package - - for name in ("run_auth_checks_with_shortfall", "scope_requirements"): - assert not hasattr(auth_package, name) - - def test_sampling_handler_resolves_from_its_submodule(self): - # The removed shim re-exported `OpenAISamplingHandler` from its package - # `__init__`. The canonical package keeps its `__init__` empty so that - # touching it never pulls in a vendor SDK, so the guide must name the - # submodule — pin both halves of that. - assert OpenAISamplingHandler is not None - - import fastmcp.client.sampling.handlers as handlers_package - - assert not hasattr(handlers_package, "OpenAISamplingHandler") - - -# --- Hard removals: modules that no longer exist --- - -REMOVED_MODULES = [ - "fastmcp.server.proxy", # -> fastmcp.server.providers.proxy - "fastmcp.server.openapi", # -> fastmcp.server.providers.openapi - "fastmcp.experimental.server.openapi", # -> fastmcp.server.providers.openapi - "fastmcp.experimental.utilities.openapi", # -> fastmcp.utilities.openapi - "fastmcp.server.apps", # -> fastmcp.apps - "fastmcp.server.app", # -> fastmcp.apps / fastmcp - # The pre-rename component modules. `tool.py`/`resource.py`/`prompt.py` are - # now `base.py`; import the types from the package itself (`from - # fastmcp.tools import Tool`) rather than naming the private module. - "fastmcp.tools.tool", # -> fastmcp.tools - "fastmcp.resources.resource", # -> fastmcp.resources - "fastmcp.prompts.prompt", # -> fastmcp.prompts - "fastmcp.experimental.sampling", # -> fastmcp.client.sampling - "fastmcp.experimental.sampling.handlers", # -> fastmcp.client.sampling.handlers - "fastmcp.server.auth.authorization", # -> fastmcp.server.auth / fastmcp.utilities.authorization -] - -# Names that were re-export shims and are gone; import them from the canonical -# module (named in each comment) instead. -REMOVED_NAMES = [ - # deprecated 3.1 -> fastmcp.server.transforms.PromptsAsTools / ResourcesAsTools - ("fastmcp.server.middleware.tool_injection", "PromptToolMiddleware"), - ("fastmcp.server.middleware.tool_injection", "ResourceToolMiddleware"), - # old misspelled names renamed to Cacheable* (no alias) codespell:ignore - ("fastmcp.server.middleware.caching", "CachableToolResult"), # codespell:ignore - ("fastmcp.server.middleware.caching", "CachablePromptResult"), # codespell:ignore - # 3.0-era rename alias -> SkillsDirectoryProvider - ("fastmcp.server.providers.skills", "SkillsProvider"), - ("fastmcp.server.providers", "SkillsProvider"), -] - - -class TestRemovedSurfacesFailLoudly: - @pytest.mark.parametrize("module_path", REMOVED_MODULES) - def test_removed_module_raises_module_not_found(self, module_path): - with pytest.raises(ModuleNotFoundError): - importlib.import_module(module_path) - - def test_mcp_types_import_path_restored_by_stable_sdk(self): - # The MCP Python SDK beta (2.0.0b2, what v4 was built against) dropped - # `mcp.types` entirely, so `from mcp.types import X` was documented as a - # hard break requiring a switch to `from mcp_types import X`. The stable - # SDK release (2.0.0) reintroduced `mcp.types` as a deliberate mirror of - # `mcp_types` — same objects, same snake_case fields, not a v1 API - # restoration — specifically so old import paths keep working. Both - # spellings resolve to the identical class. - import mcp.types - import mcp_types - - assert mcp.types.Tool is mcp_types.Tool - assert set(mcp.types.__all__) == set(mcp_types.__all__) - - @pytest.mark.parametrize( - "module_path, name", - REMOVED_NAMES, - ids=[f"{m}:{n}" for m, n in REMOVED_NAMES], - ) - def test_removed_name_is_gone(self, module_path, name): - # `from <module_path> import <name>` raises ImportError as a result. - module = importlib.import_module(module_path) - assert not hasattr(module, name) - - def test_cacheable_rename_new_name_resolves(self): - assert CacheableToolResult is not None - - @pytest.mark.parametrize( - "method_name", - [ - "as_proxy", # -> create_proxy() - "import_server", # -> mount() - "add_tool_transformation", # -> add_transform(ToolTransform(...)) - "remove_tool_transformation", # removed no-op - "remove_tool", # -> mcp.local_provider.remove_tool() - ], - ) - def test_removed_fastmcp_method_is_gone(self, method_name): - assert not hasattr(FastMCP, method_name) - - def test_mount_prefix_kwarg_removed(self): - parent = FastMCP("Parent") - child = FastMCP("Child") - # prefix= -> namespace= - with pytest.raises(TypeError): - parent.mount(child, prefix="child") # ty: ignore[unknown-argument] - - def test_mount_as_proxy_kwarg_removed(self): - parent = FastMCP("Parent") - child = FastMCP("Child") - # as_proxy= removed; wrap with create_proxy() before mounting - with pytest.raises(TypeError): - parent.mount(child, as_proxy=True) # ty: ignore[unknown-argument] - - def test_tool_serializer_kwarg_removed(self): - mcp = FastMCP("S") - # serializer= -> return a ToolResult - with pytest.raises(TypeError): - - @mcp.tool(serializer=str) # ty: ignore[no-matching-overload] - def f(x: int) -> int: - return x - - def test_tool_exclude_args_kwarg_removed(self): - mcp = FastMCP("S") - # exclude_args= -> Depends() to hide parameters - with pytest.raises(TypeError): - - @mcp.tool(exclude_args=["y"]) # ty: ignore[no-matching-overload] - def g(x: int, y: int = 1) -> int: - return x - - def test_decorator_mode_setting_removed(self): - # FASTMCP_DECORATOR_MODE / settings.decorator_mode removed entirely - assert not hasattr(settings, "decorator_mode") - - def test_streamable_http_sse_read_timeout_removed(self): - # sse_read_timeout= was a no-op under SDK v2; configure via - # read_timeout_seconds or the httpx2 client factory instead. - with pytest.raises(TypeError): - StreamableHttpTransport( - "https://example.com/mcp", - sse_read_timeout=5, # ty: ignore[unknown-argument] - ) - - def test_mcp_error_positional_construction_raises(self): - # Before: raise McpError(ErrorData(code=..., message=...)) - with pytest.raises(TypeError): - McpError(ErrorData(code=-32000, message="boom")) # ty: ignore[missing-argument, invalid-argument-type] - - def test_mcp_error_keyword_construction_works(self): - err = McpError(code=-32000, message="boom") - assert err.error.code == -32000 - assert err.error.message == "boom" - - -class TestBehaviorChanges: - """Changes that import fine but behave differently on v4.""" - - def test_client_defaults_to_auto_mode(self): - default = inspect.signature(Client.__init__).parameters["mode"].default - assert default == "auto" - - async def test_templated_resource_blocks_path_traversal(self): - mcp = FastMCP("Guarded") - - @mcp.resource("files://{path}") - def guarded(path: str) -> str: - return f"read:{path}" - - # Same template with screening disabled — the control that proves the - # rejection below is the path screen, not an unrelated URI mismatch. - @mcp.resource("open://{path}", security=None) - def unguarded(path: str) -> str: - return f"read:{path}" - - async with Client(mcp) as client: - ok = await client.read_resource("files://hello.txt") - assert ok[0].text == "read:hello.txt" - - # With screening off, a `..` value reaches the handler... - control = await client.read_resource("open://..") - assert control[0].text == "read:.." - - # ...but under the default policy it is screened before the handler - # runs and surfaces a non-leaky INVALID_PARAMS error. - with pytest.raises(McpError) as exc_info: - await client.read_resource("files://..") - assert exc_info.value.error.code == -32602 - assert "not found" in exc_info.value.error.message.lower() - - async def test_resource_not_found_uses_invalid_params_code(self): - mcp = FastMCP("NF") - - # Pin the handshake era so we read the code off the wire error directly. - async with Client(mcp, mode="legacy") as client: - with pytest.raises(McpError) as exc_info: - await client.read_resource("missing://nope") - - # SEP-2164: resource-not-found is INVALID_PARAMS (-32602), was -32002. - assert exc_info.value.error.code == -32602 diff --git a/tests/tools/test_standalone_decorator.py b/tests/tools/test_standalone_decorator.py index fc90ae13f..4d57919cd 100644 --- a/tests/tools/test_standalone_decorator.py +++ b/tests/tools/test_standalone_decorator.py @@ -25,11 +25,10 @@ from fastmcp.tools.function_tool import DecoratedTool, FunctionTool, ToolMeta "from fastmcp.resources import Resource, resource", "from fastmcp.prompts import Prompt, prompt", "import sys; import fastmcp.apps.config; assert 'fastmcp.tools.function_tool' not in sys.modules", - "from fastmcp.server.auth import AuthCheck", + "from fastmcp.server.auth.authorization import AuthCheck", "from fastmcp.server import Context, FastMCP, create_proxy", ], ) -@pytest.mark.subprocess_heavy def test_component_import_works_in_fresh_interpreter(statement: str): result = subprocess.run( [sys.executable, "-c", statement], diff --git a/tests/tools/test_tool_run_in_thread.py b/tests/tools/test_tool_run_in_thread.py index 932399cf4..069024905 100644 --- a/tests/tools/test_tool_run_in_thread.py +++ b/tests/tools/test_tool_run_in_thread.py @@ -142,7 +142,7 @@ class TestRunInThread: def blocking() -> str: import time - time.sleep(0.05) + time.sleep(0.2) return "done" ticks = 0 @@ -163,10 +163,8 @@ class TestRunInThread: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "done" - # With inline execution, ticks should be near zero — the blocking - # sleep never yields control, so at most one already-scheduled timer - # fires once control returns. Under a thread pool (default), ticks - # would scale with sleep duration instead. + # With inline execution, ticks should be near zero — the 200ms sleep + # blocks the loop. Under a thread pool (default), ticks would be ~10. assert ticks <= 2 async def test_default_threadpool_permits_concurrency(self): @@ -198,11 +196,7 @@ class TestRunInThread: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "done" - # Ideal is 0.2s / 0.02s = 10 ticks. We only require 3 (30% of ideal) - # to tolerate a loaded/slow runner, while staying well clear of the - # blocked case's `ticks <= 2` bound above so the two tests can never - # produce overlapping, ambiguous results. - assert ticks >= 3 + assert ticks >= 5 class TestRunInThreadViaStandaloneDecorator: diff --git a/tests/tools/test_tool_timeout.py b/tests/tools/test_tool_timeout.py index 43532cbee..301986f92 100644 --- a/tests/tools/test_tool_timeout.py +++ b/tests/tools/test_tool_timeout.py @@ -46,7 +46,7 @@ class TestToolTimeout: @mcp.tool(timeout=5.0) async def fast_async_tool() -> str: - await anyio.sleep(0.01) + await anyio.sleep(0.1) return "completed" result = await mcp.call_tool("fast_async_tool") @@ -59,7 +59,7 @@ class TestToolTimeout: @mcp.tool(timeout=5.0) def fast_sync_tool() -> str: - time.sleep(0.01) + time.sleep(0.1) return "completed" result = await mcp.call_tool("fast_sync_tool") @@ -72,7 +72,7 @@ class TestToolTimeout: @mcp.tool(timeout=0.2) async def slow_async_tool() -> str: - await anyio.sleep(0.6) + await anyio.sleep(2.0) return "should not reach" # TimeoutError is caught and converted to ToolError by FastMCP @@ -97,7 +97,7 @@ class TestToolTimeout: @mcp.tool(timeout=0.1) async def slow_tool() -> str: - await anyio.sleep(0.3) + await anyio.sleep(1.0) return "never" # Verify that ToolError is raised (timeout warning is logged to stderr) @@ -109,7 +109,7 @@ class TestToolTimeout: from fastmcp.tools import Tool async def my_slow_tool() -> str: - await anyio.sleep(0.3) + await anyio.sleep(1.0) return "never" tool = Tool.from_function(my_slow_tool, timeout=0.1) @@ -137,7 +137,7 @@ class TestToolTimeout: @mcp.tool(task=True, timeout=1.0) async def task_with_timeout() -> str: - await anyio.sleep(0.01) + await anyio.sleep(0.1) return "completed" # Tool should be registered successfully @@ -153,17 +153,17 @@ class TestToolTimeout: @mcp.tool(timeout=1.0) async def short_timeout() -> str: - await anyio.sleep(0.01) + await anyio.sleep(0.1) return "short" @mcp.tool(timeout=5.0) async def long_timeout() -> str: - await anyio.sleep(0.01) + await anyio.sleep(0.1) return "long" @mcp.tool async def no_timeout() -> str: - await anyio.sleep(0.01) + await anyio.sleep(0.1) return "none" # All should complete successfully @@ -184,7 +184,7 @@ class TestToolTimeout: @mcp.tool(timeout=0.1) async def times_out() -> str: - await anyio.sleep(0.3) + await anyio.sleep(1.0) return "never" # TimeoutError should be caught and converted to ToolError diff --git a/tests/tools/tool/test_argument_validation.py b/tests/tools/tool/test_argument_validation.py index a526eae3f..8f41cea5d 100644 --- a/tests/tools/tool/test_argument_validation.py +++ b/tests/tools/tool/test_argument_validation.py @@ -86,32 +86,23 @@ class TestToolBodyErrors: class TestTaskArgumentValidation: - """The task-execution path (coerce_task_arguments) converts arg errors too. - - The coercion logic moved to ``fastmcp_tasks.components`` during the - SEP-1686 -> SEP-2663 migration, keyed by component type instead of being a - method on the component. - """ + """The task-execution path (coerce_task_arguments) converts arg errors too.""" def test_coerce_task_arguments_wrong_type(self): - from fastmcp_tasks.components import coerce_task_arguments - def tool_fn(n: int) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): - coerce_task_arguments(tool, {"n": "not-an-int"}) + tool.coerce_task_arguments({"n": "not-an-int"}) def test_coerce_task_arguments_constraint_violation(self): - from fastmcp_tasks.components import coerce_task_arguments - def tool_fn(n: Annotated[int, Field(le=10)]) -> int: return n tool = Tool.from_function(tool_fn) with pytest.raises(ValidationError): - coerce_task_arguments(tool, {"n": 20}) + tool.coerce_task_arguments({"n": 20}) class TestValidCallsStillWork: diff --git a/tests/tools/tool/test_output_schema.py b/tests/tools/tool/test_output_schema.py index c650134d9..d63089bbb 100644 --- a/tests/tools/tool/test_output_schema.py +++ b/tests/tools/tool/test_output_schema.py @@ -3,13 +3,7 @@ from typing import Annotated, Any import pytest from inline_snapshot import snapshot -from mcp_types import ( - AudioContent, - CallToolResult, - EmbeddedResource, - ImageContent, - TextContent, -) +from mcp_types import AudioContent, EmbeddedResource, ImageContent, TextContent from pydantic import AnyUrl, BaseModel, Field, TypeAdapter from typing_extensions import TypedDict @@ -136,13 +130,6 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func) assert tool.output_schema is None - async def test_call_tool_result_return_annotation_no_output_schema(self): - def func() -> CallToolResult: - return CallToolResult(content=[]) - - tool = Tool.from_function(func) - assert tool.output_schema is None - async def test_tool_result_subclass_return_annotation_no_output_schema(self): class MyToolResult(ToolResult): def __init__(self, data: str): @@ -231,10 +218,6 @@ class TestToolFromFunctionOutputSchema: tool = Tool.from_function(func) assert tool.output_schema is None - result = await tool.run({}) - assert result.structured_content is None - assert len(result.content) == 1 - async def test_mixed_unserializable_return_annotation(self): class Unserializable: def __init__(self, data: Any): diff --git a/tests/tools/tool/test_results.py b/tests/tools/tool/test_results.py index 9edcbb327..830d5f1f1 100644 --- a/tests/tools/tool/test_results.py +++ b/tests/tools/tool/test_results.py @@ -4,11 +4,10 @@ from typing import Annotated, Any import pytest from mcp_types import CallToolResult, TextContent -from pydantic import BaseModel, ConfigDict, Field, with_config +from pydantic import BaseModel, ConfigDict, Field from fastmcp import Client, FastMCP from fastmcp.tools.base import Tool, ToolResult -from tests.conftest import user_meta class TestToolResultCasting: @@ -40,7 +39,7 @@ class TestToolResultCasting: assert result.content[0].type == "text" assert result.content[0].text == "test data" assert result.structured_content is None - assert user_meta(result.meta) is None + assert result.meta is None async def test_neither_unstructured_or_structured_content(self, client): from fastmcp.exceptions import ToolError @@ -57,7 +56,7 @@ class TestToolResultCasting: assert result.content[0].type == "text" assert result.content[0].text == "test data" assert result.structured_content == {"data_type": "test"} - assert user_meta(result.meta) is None + assert result.meta is None async def test_structured_unstructured_and_meta_content(self, client): result = await client.call_tool( @@ -72,7 +71,7 @@ class TestToolResultCasting: assert result.content[0].type == "text" assert result.content[0].text == "test data" assert result.structured_content == {"data_type": "test"} - assert user_meta(result.meta) == {"some": "metadata"} + assert result.meta == {"some": "metadata"} class TestToolResultIsError: @@ -124,43 +123,6 @@ class TestToolResultIsError: assert result.is_error is True assert result.content[0].text == "upstream boom" - def test_raw_call_tool_result_is_preserved(self): - tool = Tool.from_function(lambda: None, name="test_tool") - raw_result = CallToolResult( - content=[TextContent(type="text", text="upstream boom")], - structured_content={"code": 42}, - is_error=True, - _meta={"source": "upstream"}, - ) - - result = tool.convert_result(raw_result) - - assert result.to_mcp_result() is raw_result - - async def test_raw_call_tool_result_preserves_protocol_fields(self): - mcp = FastMCP() - - raw_result = CallToolResult( - content=[TextContent(type="text", text="upstream boom")], - structured_content={"code": 42}, - is_error=True, - _meta={"source": "upstream"}, - ) - - @mcp.tool - def failing() -> CallToolResult: - return raw_result - - async with Client(mcp) as client: - result = await client.call_tool_mcp("failing", {}) - - received = result.model_dump(by_alias=True) - # The SDK stamps `serverInfo` into every 2026-era result's `_meta` - # (spec #3002). Strip it so the assertion covers the protocol fields - # the tool itself set, which is what FastMCP is responsible for. - received["_meta"] = user_meta(received["_meta"]) - assert received == raw_result.model_dump(by_alias=True) - class TestUnionReturnTypes: """Tests for tools with union return types.""" @@ -200,7 +162,6 @@ class TestSerializationAlias: class Component(BaseModel): """Model with multiple validation aliases but specific serialization alias.""" - model_config = ConfigDict(serialize_by_alias=True) component_id: str = Field( validation_alias=AliasChoices("id", "componentId"), serialization_alias="componentId", @@ -244,7 +205,6 @@ class TestSerializationAlias: class Component(BaseModel): """Model with multiple validation aliases but specific serialization alias.""" - model_config = ConfigDict(serialize_by_alias=True) component_id: str = Field( validation_alias=AliasChoices("id", "componentId"), serialization_alias="componentId", @@ -279,7 +239,12 @@ class TestSerializationAlias: class TestSerializeByAlias: - """Tests that typed results use Pydantic's serialization behavior.""" + """Tests that a model's serialize_by_alias config is honored at runtime. + + pydantic_core's serialization helpers default by_alias to True, which + silently ignores serialize_by_alias=False. The serialized result and the + generated output schema must both reflect the model's configured behavior. + """ async def test_serialize_by_alias_false_uses_field_names(self): """serialize_by_alias=False emits field names in schema, structured, and text.""" @@ -309,8 +274,8 @@ class TestSerializeByAlias: "filepath", } - async def test_unset_config_uses_pydantic_default(self): - """A model with no serialize config uses Pydantic's field-name default.""" + async def test_unset_config_preserves_alias_default(self): + """A model with an alias but no serialize config keeps emitting the alias.""" class Biofile(BaseModel): id: str = Field(alias="_id") @@ -326,96 +291,14 @@ class TestSerializeByAlias: tools = {t.name: t for t in await client.list_tools()} result = await client.call_tool("get_biofile", {}) - assert result.structured_content == {"id": "123", "filepath": "/p"} + assert result.structured_content == {"_id": "123", "filepath": "/p"} assert set(tools["get_biofile"].output_schema["properties"]) == { # type: ignore[index] - "id", + "_id", "filepath", } - async def test_model_in_typed_mapping_respects_config(self): - """A typed mapping's schema and result use the model's field names.""" - - class Biofile(BaseModel): - model_config = ConfigDict(serialize_by_alias=False) - id: str = Field(alias="_id") - - mcp = FastMCP() - - @mcp.tool - def get_biofiles() -> dict[str, Biofile]: - return {"first": Biofile(_id="1")} - - async with Client(mcp) as client: - tools = {tool.name: tool for tool in await client.list_tools()} - result = await client.call_tool("get_biofiles", {}) - - value_schema = tools["get_biofiles"].output_schema["additionalProperties"] # type: ignore[index] - assert set(value_schema["properties"]) == {"id"} - assert result.structured_content == {"first": {"id": "1"}} - - async def test_nested_models_use_their_own_alias_configs(self): - """Nested models can independently enable and disable aliases.""" - - class NamedValue(BaseModel): - model_config = ConfigDict(serialize_by_alias=False) - value: str = Field(serialization_alias="namedValue") - - class AliasedValue(BaseModel): - model_config = ConfigDict(serialize_by_alias=True) - value: str = Field(serialization_alias="aliasedValue") - - class Output(BaseModel): - named: NamedValue - aliased: AliasedValue - - mcp = FastMCP() - - @mcp.tool - def get_output() -> Output: - return Output( - named=NamedValue(value="named"), - aliased=AliasedValue(value="aliased"), - ) - - async with Client(mcp) as client: - tools = {tool.name: tool for tool in await client.list_tools()} - result = await client.call_tool("get_output", {}) - - properties = tools["get_output"].output_schema["properties"] # type: ignore[index] - assert set(properties["named"]["properties"]) == {"value"} - assert set(properties["aliased"]["properties"]) == {"aliasedValue"} - assert result.structured_content == { - "named": {"value": "named"}, - "aliased": {"aliasedValue": "aliased"}, - } - - async def test_typed_dataclass_container_uses_declared_adapter(self): - """A typed container preserves its dataclass's alias configuration.""" - - @with_config(ConfigDict(serialize_by_alias=True)) - @dataclass - class Output: - value: Annotated[str, Field(serialization_alias="dataValue")] - - mcp = FastMCP() - - @mcp.tool - def get_output() -> list[Output]: - return [Output(value="data")] - - async with Client(mcp) as client: - tools = {tool.name: tool for tool in await client.list_tools()} - result = await client.call_tool("get_output", {}) - - item_schema = tools["get_output"].output_schema["properties"]["result"][ # type: ignore[index] - "items" - ] - assert set(item_schema["properties"]) == {"dataValue"} - assert result.structured_content == {"result": [{"dataValue": "data"}]} - assert json.loads(result.content[0].text) == [{"dataValue": "data"}] # type: ignore[union-attr] - async def test_serialize_by_alias_true_uses_alias(self): - """serialize_by_alias=True emits aliases.""" + """serialize_by_alias=True emits aliases, same as the default.""" class Biofile(BaseModel): model_config = ConfigDict(serialize_by_alias=True) @@ -433,3 +316,80 @@ class TestSerializeByAlias: assert result.structured_content == {"_id": "123"} assert set(tools["get_biofile"].output_schema["properties"]) == {"_id"} # type: ignore[index] + + async def test_nested_models_respect_config(self): + """serialize_by_alias=False propagates through nested models.""" + + class Inner(BaseModel): + model_config = ConfigDict(serialize_by_alias=False) + inner_id: str = Field(alias="_iid") + + class Outer(BaseModel): + model_config = ConfigDict(serialize_by_alias=False) + id: str = Field(alias="_id") + inner: Inner + + mcp = FastMCP() + + @mcp.tool + def get_outer() -> Outer: + return Outer(_id="1", inner=Inner(_iid="2")) + + async with Client(mcp) as client: + result = await client.call_tool("get_outer", {}) + + assert result.structured_content == {"id": "1", "inner": {"inner_id": "2"}} + + async def test_annotated_optional_return_stays_consistent(self): + """Annotated[Model, ...] | None resolves the model inside the union arm. + + Regression: the union arm is a typing.Annotated object, so a naive + isinstance check skipped the model and the schema fell back to aliases + while the runtime serialized field names, breaking client validation. + """ + + class Biofile(BaseModel): + model_config = ConfigDict(serialize_by_alias=False) + id: str = Field(alias="_id") + + mcp = FastMCP() + + @mcp.tool + def get_biofile() -> Annotated[Biofile, Field(description="x")] | None: + return Biofile(_id="1") + + async with Client(mcp) as client: + tools = {t.name: t for t in await client.list_tools()} + # client-side validation of structured content against the schema + # raises if they disagree + result = await client.call_tool("get_biofile", {}) + + schema_props = set(tools["get_biofile"].output_schema["properties"]) # type: ignore[index] + assert schema_props == set(result.structured_content) # type: ignore[arg-type] + assert result.structured_content == {"result": {"id": "1"}} + + @pytest.mark.parametrize("serialize_by_alias", [True, False, None]) + async def test_schema_and_structured_content_agree(self, serialize_by_alias): + """The output schema field names always match the structured content keys.""" + if serialize_by_alias is None: + config = ConfigDict() + else: + config = ConfigDict(serialize_by_alias=serialize_by_alias) + + class Model(BaseModel): + model_config = config + id: str = Field(alias="_id") + name: str + + mcp = FastMCP() + + @mcp.tool + def get_model() -> Model: + return Model(_id="1", name="x") + + async with Client(mcp) as client: + tools = {t.name: t for t in await client.list_tools()} + result = await client.call_tool("get_model", {}) + + schema_props = set(tools["get_model"].output_schema["properties"]) # type: ignore[index] + assert schema_props == set(result.structured_content) # type: ignore[arg-type] diff --git a/tests/tools/tool/test_title.py b/tests/tools/tool/test_title.py index fde193f70..29c0c1a73 100644 --- a/tests/tools/tool/test_title.py +++ b/tests/tools/tool/test_title.py @@ -1,6 +1,3 @@ -import pytest -from mcp_types import ToolAnnotations - from fastmcp.tools.base import Tool @@ -33,13 +30,7 @@ class TestToolTitle: ) def test_tool_without_title(self): - """Test that tools without an explicit title derive one from the name. - - Some MCP clients (e.g. ChatGPT) drop tools with no `title` rather - than falling back to `name` as the spec allows, so FastMCP always - emits a derived title on the wire instead of relying on that - fallback. - """ + """Test that tools without titles use name as display name.""" def multiply(a: int, b: int) -> int: return a * b @@ -49,40 +40,14 @@ class TestToolTitle: assert tool.name == "multiply" assert tool.title is None + # Test MCP conversion doesn't include title when None mcp_tool = tool.to_mcp_tool() assert mcp_tool.name == "multiply" - assert mcp_tool.title == "Multiply" - - def test_derived_title_follows_name_override(self): - """The derived title should reflect a `name` override, not the original name.""" - - def multiply(a: int, b: int) -> int: - return a * b - - tool = Tool.from_function(multiply, name="multiply_tool") - - mcp_tool = tool.to_mcp_tool(name="renamed_tool") - assert mcp_tool.name == "renamed_tool" - assert mcp_tool.title == "Renamed Tool" - - @pytest.mark.parametrize( - "annotations", - [ToolAnnotations(title="Custom"), {"title": "Custom"}], - ids=["object", "dict"], - ) - def test_annotations_override_beats_derived_title(self, annotations): - """An `annotations` override still outranks the name-derived title.""" - - def multiply(a: int, b: int) -> int: - return a * b - - tool = Tool.from_function(multiply) - - mcp_tool = tool.to_mcp_tool(annotations=annotations) - assert mcp_tool.title == "Custom" + assert not hasattr(mcp_tool, "title") or mcp_tool.title is None def test_tool_title_priority(self): """Test that explicit title takes priority over annotations.title.""" + from mcp_types import ToolAnnotations def divide(x: int, y: int) -> float: """Divide two numbers.""" @@ -107,6 +72,7 @@ class TestToolTitle: def test_tool_annotations_title_fallback(self): """Test that annotations.title is used when no explicit title is provided.""" + from mcp_types import ToolAnnotations def modulo(x: int, y: int) -> int: """Get modulo of two numbers.""" diff --git a/tests/tools/tool_transform/test_metadata.py b/tests/tools/tool_transform/test_metadata.py index 543339018..36384afec 100644 --- a/tests/tools/tool_transform/test_metadata.py +++ b/tests/tools/tool_transform/test_metadata.py @@ -172,45 +172,6 @@ def test_tool_transform_config_removes_meta(sample_tool): assert transformed.meta is None -def test_meta_override_preserves_fastmcp_namespace(sample_tool): - """A meta override replaces caller meta but keeps framework-owned data. - - The fastmcp namespace carries app membership and the identity hash that - intermediaries match on. A rename via config must not destroy it. - """ - sample_tool.meta = {"original": True, "fastmcp": {"app": "crm", "tool_hash": "abc"}} - transformed = Tool.from_tool(sample_tool, meta={"custom": True}) - assert transformed.meta == { - "custom": True, - "fastmcp": {"app": "crm", "tool_hash": "abc"}, - } - - -def test_meta_none_preserves_fastmcp_namespace(sample_tool): - """Clearing meta clears caller meta, not the framework namespace.""" - sample_tool.meta = {"original": True, "fastmcp": {"app": "crm", "tool_hash": "abc"}} - transformed = Tool.from_tool(sample_tool, meta=None) - assert transformed.meta == {"fastmcp": {"app": "crm", "tool_hash": "abc"}} - - -def test_meta_override_can_extend_fastmcp_namespace(sample_tool): - """An override may add to the fastmcp namespace without dropping its keys.""" - sample_tool.meta = {"fastmcp": {"app": "crm", "tool_hash": "abc"}} - transformed = Tool.from_tool(sample_tool, meta={"fastmcp": {"extra": 1}}) - assert transformed.meta == { - "fastmcp": {"app": "crm", "tool_hash": "abc", "extra": 1} - } - - -def test_config_meta_override_preserves_identity_hash(sample_tool): - """The fastmcp.json `tools:` path goes through the same preservation.""" - sample_tool.meta = {"fastmcp": {"app": "crm", "tool_hash": "abc"}} - config = ToolTransformConfig(name="renamed", meta={"team": "growth"}) - transformed = config.apply(sample_tool) - assert transformed.meta is not None - assert transformed.meta["fastmcp"]["tool_hash"] == "abc" - - # Enabled field tests def test_tool_transform_config_enabled_defaults_to_true(sample_tool): """Test that enabled defaults to True and no visibility metadata is set.""" diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index 3dc89239b..f9e50df72 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -1,13 +1,11 @@ """Core tool transform functionality.""" -import json import re -from dataclasses import dataclass from typing import Annotated, Any import pytest from mcp_types import TextContent -from pydantic import BaseModel, ConfigDict, Field, with_config +from pydantic import BaseModel, Field from fastmcp import FastMCP from fastmcp.client.client import Client @@ -43,23 +41,6 @@ def test_tool_from_tool_no_change(add_tool): assert new_tool.description == add_tool.description -def test_transformed_tool_required_order_is_deterministic(): - """`required` must follow property order, not set iteration order. - - Set iteration order varies with PYTHONHASHSEED, which broke snapshot - tests of tools/list output across processes. - """ - - def fn(alpha: int, beta: str, gamma: float, delta: bool, epsilon: int) -> str: - return "x" - - base = Tool.from_function(fn) - transformed = Tool.from_tool(base, transform_args={"alpha": ArgTransform(name="a")}) - props = list(transformed.parameters["properties"]) - assert transformed.parameters["required"] == props - assert props == ["a", "beta", "gamma", "delta", "epsilon"] - - def test_from_tool_accepts_decorated_function(): @tool def search(q: str, limit: int = 10) -> list[str]: @@ -724,28 +705,6 @@ async def test_transform_fn_wrapped_result_respects_serialize_by_alias(): assert result.structured_content == {"result": {"id": "42"}} -async def test_transform_fn_configured_dataclass_respects_serialize_by_alias(): - """A transform uses its return annotation for nested dataclass serialization.""" - - @with_config(ConfigDict(serialize_by_alias=True)) - @dataclass - class Item: - id: Annotated[str, Field(serialization_alias="itemId")] - - def base() -> None: - pass - - async def transform() -> list[Item]: - return [Item(id="42")] - - transformed = Tool.from_tool(base, transform_fn=transform) - result = await transformed.run({}) - - assert result.structured_content == {"result": [{"itemId": "42"}]} - assert isinstance(result.content[0], TextContent) - assert json.loads(result.content[0].text) == [{"itemId": "42"}] - - class TestProxy: @pytest.fixture def mcp_server(self) -> FastMCP: diff --git a/tests/utilities/httpx2_mock.py b/tests/utilities/httpx2_mock.py index b565a849f..911b29a9d 100644 --- a/tests/utilities/httpx2_mock.py +++ b/tests/utilities/httpx2_mock.py @@ -55,11 +55,9 @@ class _Matcher: self, url: str | re.Pattern[str] | httpx2.URL | None, method: str | None, - is_optional: bool = False, ) -> None: self.url = httpx2.URL(url) if isinstance(url, str) else url self.method = method.upper() if method else method - self.is_optional = is_optional self.nb_calls = 0 def match(self, request: httpx2.Request) -> bool: @@ -107,7 +105,6 @@ class HTTPXMock: *, url: str | re.Pattern[str] | httpx2.URL | None = None, method: str | None = None, - is_optional: bool = False, ) -> None: json = copy.deepcopy(json) if json is not None else None @@ -122,7 +119,7 @@ class HTTPXMock: stream=stream, ) - self._callbacks.append((_Matcher(url, method, is_optional), callback)) + self._callbacks.append((_Matcher(url, method), callback)) def add_exception( self, @@ -206,9 +203,7 @@ class HTTPXMock: def _assert_options(self) -> None: not_requested = [ - str(matcher) - for matcher, _ in self._callbacks - if not matcher.nb_calls and not matcher.is_optional + str(matcher) for matcher, _ in self._callbacks if not matcher.nb_calls ] assert not not_requested, ( "The following responses are mocked but not requested:\n" diff --git a/tests/utilities/json_schema_type/test_json_schema_type.py b/tests/utilities/json_schema_type/test_json_schema_type.py index 0c79f0a1c..c125ddc98 100644 --- a/tests/utilities/json_schema_type/test_json_schema_type.py +++ b/tests/utilities/json_schema_type/test_json_schema_type.py @@ -4,7 +4,7 @@ import dataclasses import warnings from dataclasses import Field from enum import Enum -from typing import Any, Literal, cast +from typing import Any, Literal import pytest from pydantic import TypeAdapter, ValidationError @@ -294,7 +294,7 @@ class TestCrashPrevention: }, } T = json_schema_to_type(schema) - field_names = [f.name for f in dataclasses.fields(cast(Any, T))] + field_names = [f.name for f in dataclasses.fields(T)] assert len(field_names) == 2 assert len(set(field_names)) == 2 @@ -308,7 +308,7 @@ class TestCrashPrevention: }, } T = json_schema_to_type(schema) - field_names = [f.name for f in dataclasses.fields(cast(Any, T))] + field_names = [f.name for f in dataclasses.fields(T)] assert len(field_names) == 2 assert len(set(field_names)) == 2 diff --git a/tests/utilities/test_asgi_transport.py b/tests/utilities/test_asgi_transport.py deleted file mode 100644 index 5a473b5c1..000000000 --- a/tests/utilities/test_asgi_transport.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Tests for the in-process ASGI bridge and `asgi_server`.""" - -from typing import Literal - -import httpx2 -import pytest -from starlette.applications import Starlette -from starlette.requests import Request -from starlette.responses import Response, StreamingResponse -from starlette.routing import Route -from starlette.types import Receive, Scope, Send - -from fastmcp import Context, FastMCP -from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair -from fastmcp.utilities.asgi_transport import StreamingASGITransport, run_asgi_lifespan -from fastmcp.utilities.tests import ASGIServer, asgi_server - - -def build_server() -> FastMCP: - server = FastMCP("BridgeTestServer") - - @server.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - @server.tool - async def elicit_name(ctx: Context) -> str: - """Round-trips a server-initiated request while the response is still open.""" - result = await ctx.elicit("What is your name?", response_type=str) - if result.action == "accept": - return f"You said {result.data}" - return "declined" - - return server - - -class TestStreamingASGITransport: - async def test_forwards_chunks_as_the_app_produces_them(self): - """The bridge must stream, not buffer: chunks arrive before the app finishes.""" - - async def stream(request: Request) -> StreamingResponse: - async def body(): - yield b"first" - yield b"second" - - return StreamingResponse(body(), media_type="text/plain") - - app = Starlette(routes=[Route("/stream", stream)]) - async with httpx2.AsyncClient( - transport=StreamingASGITransport(app), base_url="http://testserver" - ) as client: - chunks: list[bytes] = [] - async with client.stream("GET", "/stream") as response: - async for chunk in response.aiter_bytes(): - chunks.append(chunk) - - assert b"".join(chunks) == b"firstsecond" - - async def test_request_body_and_headers_reach_the_app(self): - async def echo(request: Request) -> Response: - body = await request.body() - return Response( - content=body, - headers={"x-seen-header": request.headers.get("x-demo", "missing")}, - ) - - app = Starlette(routes=[Route("/echo", echo, methods=["POST"])]) - async with httpx2.AsyncClient( - transport=StreamingASGITransport(app), base_url="http://testserver" - ) as client: - response = await client.post( - "/echo", content=b"payload", headers={"x-demo": "abc"} - ) - - assert response.content == b"payload" - assert response.headers["x-seen-header"] == "abc" - - async def test_query_string_reaches_the_app(self): - async def show(request: Request) -> Response: - return Response(content=request.query_params["q"]) - - app = Starlette(routes=[Route("/search", show)]) - async with httpx2.AsyncClient( - transport=StreamingASGITransport(app), base_url="http://testserver" - ) as client: - response = await client.get("/search", params={"q": "hello"}) - - assert response.text == "hello" - - async def test_error_before_response_start_propagates_to_caller(self): - async def boom(scope: Scope, receive: Receive, send: Send) -> None: - raise ValueError("app exploded") - - async with httpx2.AsyncClient( - transport=StreamingASGITransport(boom), base_url="http://testserver" - ) as client: - with pytest.raises(ValueError, match="app exploded"): - await client.get("/anything") - - async def test_error_after_response_start_truncates_the_body(self): - """Post-start failures look like a dropped socket, not a raised exception.""" - - async def boom(scope: Scope, receive: Receive, send: Send) -> None: - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [(b"content-type", b"text/plain")], - } - ) - # Raise with no checkpoint in between, so the error is guaranteed to be - # recorded before the transport's waiter resumes — the scheduling order - # that used to surface the failure as a raised exception. - raise ValueError("app exploded mid-response") - - async with httpx2.AsyncClient( - transport=StreamingASGITransport(boom), base_url="http://testserver" - ) as client: - response = await client.get("/anything") - - assert response.status_code == 200 - assert response.content == b"" - - -class TestRunAsgiLifespan: - async def test_startup_and_shutdown_run_once(self): - events: list[str] = [] - - async def app(scope: Scope, receive: Receive, send: Send) -> None: - assert scope["type"] == "lifespan" - while True: - message = await receive() - if message["type"] == "lifespan.startup": - events.append("startup") - await send({"type": "lifespan.startup.complete"}) - elif message["type"] == "lifespan.shutdown": - events.append("shutdown") - await send({"type": "lifespan.shutdown.complete"}) - return - - async with run_asgi_lifespan(app): - assert events == ["startup"] - - assert events == ["startup", "shutdown"] - - async def test_startup_failure_raises(self): - async def app(scope: Scope, receive: Receive, send: Send) -> None: - await receive() - await send({"type": "lifespan.startup.failed", "message": "nope"}) - - with pytest.raises(RuntimeError, match="startup failed"): - async with run_asgi_lifespan(app): - pass - - async def test_shutdown_failure_raises(self): - async def app(scope: Scope, receive: Receive, send: Send) -> None: - await receive() - await send({"type": "lifespan.startup.complete"}) - await receive() - await send({"type": "lifespan.shutdown.failed", "message": "teardown nope"}) - - with pytest.raises(RuntimeError, match="shutdown failed"): - async with run_asgi_lifespan(app): - pass - - async def test_shutdown_failure_does_not_mask_body_error(self): - """A broken teardown must never hide the failure the caller actually cares about.""" - - async def app(scope: Scope, receive: Receive, send: Send) -> None: - await receive() - await send({"type": "lifespan.startup.complete"}) - await receive() - await send({"type": "lifespan.shutdown.failed", "message": "teardown nope"}) - - with pytest.raises(ValueError, match="body exploded"): - async with run_asgi_lifespan(app): - raise ValueError("body exploded") - - -class TestRunServerInMemory: - @pytest.mark.parametrize("transport", ["http", "streamable-http", "sse"]) - async def test_client_round_trip( - self, transport: Literal["http", "streamable-http", "sse"] - ): - async with asgi_server(build_server(), transport=transport) as server: - async with server.client() as client: - result = await client.call_tool("greet", {"name": "World"}) - - assert result.data == "Hello, World!" - - async def test_default_path_matches_transport(self): - async with asgi_server(build_server(), transport="sse") as server: - assert server.url.endswith("/sse") - async with asgi_server(build_server(), transport="http") as server: - assert server.url.endswith("/mcp") - - async def test_custom_path_is_used(self): - async with asgi_server(build_server(), path="/custom") as server: - assert server.url.endswith("/custom") - # `ping` exists only in the handshake era, so this pins that era. - async with server.client(mode="legacy") as client: - assert await client.ping() is True - - async def test_server_initiated_request_mid_stream(self): - """Elicitation needs a server->client request while the POST is still open. - - This is the capability a buffering ASGI transport cannot provide, and the - reason the bridge streams responses. - """ - - async def elicitation_handler(message, response_type, params, ctx): - return {"value": "Alice"} - - async with asgi_server(build_server()) as server: - # Server-initiated elicitation is handshake-era only, and it is the - # mid-stream request this test exists to exercise, so pin that era. - async with server.client( - elicitation_handler=elicitation_handler, mode="legacy" - ) as client: - result = await client.call_tool("elicit_name", {}) - - assert result.data == "You said Alice" - - async def test_http_client_reaches_the_app(self): - async with asgi_server(build_server()) as server: - async with server.http_client() as http: - # A GET on the streamable HTTP endpoint without a session is rejected; - # the point is that the request reaches the real app at all. - response = await http.get(server.url) - - assert response.status_code in {400, 405} - - async def test_auth_middleware_runs(self): - """A migrated test must not pass by bypassing the real middleware stack.""" - key_pair = RSAKeyPair.generate() - server = build_server() - server.auth = JWTVerifier( - public_key=key_pair.public_key, - issuer="https://issuer.example.com", - audience="test-audience", - ) - - async with asgi_server(server) as running_server: - async with running_server.http_client() as http: - unauthenticated = await http.post( - running_server.url, - json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}, - ) - - assert unauthenticated.status_code == 401 - - async def test_yields_in_memory_server(self): - async with asgi_server(build_server()) as server: - assert isinstance(server, ASGIServer) - assert server.transport_type == "http" diff --git a/tests/utilities/test_async_utils.py b/tests/utilities/test_async_utils.py index 7f9d02d5d..6d1366613 100644 --- a/tests/utilities/test_async_utils.py +++ b/tests/utilities/test_async_utils.py @@ -1,11 +1,7 @@ """Tests for fastmcp.utilities.async_utils.""" import functools -import inspect -from collections.abc import Awaitable, Iterator -from typing import Any -import anyio import pytest from exceptiongroup import BaseExceptionGroup @@ -13,7 +9,6 @@ from fastmcp import Client, FastMCP from fastmcp.prompts import prompt from fastmcp.resources import resource from fastmcp.tools import tool -from fastmcp.utilities import async_utils from fastmcp.utilities.async_utils import gather, is_coroutine_function @@ -60,20 +55,14 @@ class TestGather: async def value(result: int) -> int: return result - assert await gather([value(1), value(2), value(3)]) == [1, 2, 3] - - async def test_accepts_a_generator(self) -> None: - async def value(result: int) -> int: - return result - - assert await gather(value(i) for i in [1, 2, 3]) == [1, 2, 3] + assert await gather(value(1), value(2), value(3)) == [1, 2, 3] async def test_raises_by_default(self) -> None: async def fail() -> int: raise RuntimeError("boom") with pytest.raises(BaseExceptionGroup) as exc_info: - await gather([fail()]) + await gather(fail()) assert len(exc_info.value.exceptions) == 1 assert isinstance(exc_info.value.exceptions[0], RuntimeError) @@ -85,118 +74,11 @@ class TestGather: async def value() -> int: return 1 - result = await gather([fail(), value()], return_exceptions=True) + result = await gather(fail(), value(), return_exceptions=True) assert isinstance(result[0], ValueError) assert result[1] == 1 - async def test_does_not_leak_coroutine_when_scheduling_is_interrupted( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """If handing an already-created awaitable off to the task group - raises partway through scheduling, that awaitable must be closed - rather than silently garbage collected later - which is what - produces a "coroutine was never awaited" RuntimeWarning attributed - to whatever unrelated code happens to be running when the garbage - collector eventually reclaims it. - - In production this can happen when a synchronous signal handler - (e.g. pytest-timeout's SIGALRM-based per-test timeout) fires inside - anyio's task-spawning internals. This test reproduces the same - shape of interruption deterministically by making the task group's - ``start_soon`` raise partway through scheduling, instead of relying - on real signal timing. - """ - real_create_task_group = anyio.create_task_group - - class _FailOnSecondStart: - def __init__(self) -> None: - self._real_tg = real_create_task_group() - self._calls = 0 - - async def __aenter__(self) -> "_FailOnSecondStart": - await self._real_tg.__aenter__() - return self - - async def __aexit__(self, *exc_info: Any) -> bool | None: - return await self._real_tg.__aexit__(*exc_info) - - def start_soon(self, func: Any, *args: Any) -> None: - self._calls += 1 - if self._calls == 2: - raise RuntimeError("interrupted while scheduling") - self._real_tg.start_soon(func, *args) - - monkeypatch.setattr(async_utils.anyio, "create_task_group", _FailOnSecondStart) - - created: list[Any] = [] - - async def value(result: int) -> int: - return result - - def awaitables() -> Iterator[Awaitable[int]]: - for i in range(3): - aw = value(i) - created.append(aw) - yield aw - - with pytest.raises(BaseExceptionGroup) as exc_info: - await gather(awaitables()) - - assert len(exc_info.value.exceptions) == 1 - assert isinstance(exc_info.value.exceptions[0], RuntimeError) - assert "interrupted while scheduling" in str(exc_info.value.exceptions[0]) - - # created[1] was being handed to start_soon() when it raised - it - # must have been closed rather than abandoned. - assert inspect.getcoroutinestate(created[1]) == "CORO_CLOSED" - - async def test_closes_unscheduled_coroutines_from_an_eager_caller( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Lazy consumption keeps the leak window small, but a caller that - builds its awaitables eagerly (a list or a parenthesized tuple) has - coroutines queued behind the failing one that were never scheduled - either. ``gather`` drains what is left of the iterable and closes - those too, so it cannot leak regardless of how its argument was - constructed.""" - real_create_task_group = anyio.create_task_group - - class _FailOnSecondStart: - def __init__(self) -> None: - self._real_tg = real_create_task_group() - self._calls = 0 - - async def __aenter__(self) -> "_FailOnSecondStart": - await self._real_tg.__aenter__() - return self - - async def __aexit__(self, *exc_info: Any) -> bool | None: - return await self._real_tg.__aexit__(*exc_info) - - def start_soon(self, func: Any, *args: Any) -> None: - self._calls += 1 - if self._calls == 2: - raise RuntimeError("interrupted while scheduling") - self._real_tg.start_soon(func, *args) - - monkeypatch.setattr(async_utils.anyio, "create_task_group", _FailOnSecondStart) - - async def value(result: int) -> int: - return result - - # Eagerly built: all four coroutines exist before gather() runs. - eager = [value(0), value(1), value(2), value(3)] - - with pytest.raises(BaseExceptionGroup): - await gather(eager) - - # The one that failed to schedule *and* the two queued behind it are - # all closed; none is left to surface as a stray warning later. - assert [inspect.getcoroutinestate(aw) for aw in eager[1:]] == [ - "CORO_CLOSED" - ] * 3 - class TestAsyncPartialIntegration: async def test_async_partial_tool_runs(self) -> None: diff --git a/tests/utilities/test_json_schema.py b/tests/utilities/test_json_schema.py index b97153b02..8d16665da 100644 --- a/tests/utilities/test_json_schema.py +++ b/tests/utilities/test_json_schema.py @@ -1,6 +1,4 @@ import copy -import sys -from typing import Any from unittest.mock import patch from jsonref import replace_refs @@ -14,31 +12,6 @@ from fastmcp.utilities.json_schema import ( ) -def _measure_depth(schema: dict[str, Any]) -> int: - """Return how many `items` levels deep an array-nested schema goes. - - Walks iteratively so the assertion helpers cannot themselves hit the - recursion limit the tests are probing. - """ - depth = 0 - node: Any = schema - while isinstance(node.get("items"), dict): - node = node["items"] - depth += 1 - return depth - - -def _count_titles(schema: dict[str, Any]) -> int: - """Count the `title` keys down an array-nested schema, iteratively.""" - count = 0 - node: Any = schema - while isinstance(node, dict): - if "title" in node: - count += 1 - node = node.get("items") - return count - - class TestPruneParam: """Tests for the _prune_param function.""" @@ -418,51 +391,6 @@ class TestCompressSchema: assert "additionalProperties" not in result assert "$defs" not in result - def test_compresses_schema_nested_far_beyond_the_recursion_limit(self): - """Deeply nested schemas must compress rather than raise RecursionError. - - Copying the schema is what sets the depth ceiling, so it must not - recurse: schemas this deep arrive from proxied or remote MCP servers, - and failing to compress them is worse than compressing them partially. - """ - depth = sys.getrecursionlimit() * 2 - - schema: dict[str, Any] = {"type": "string", "title": "Leaf"} - for _ in range(depth): - schema = {"type": "array", "title": "Level", "items": schema} - - original_depth = _measure_depth(schema) - - result = compress_schema(schema, prune_titles=True) - - assert result is not schema - assert _measure_depth(result) == original_depth - # The caller's schema is still intact at every level... - assert _count_titles(schema) == original_depth + 1 - # ...and the copy really was pruned as deep as the traversal reaches. - assert _count_titles(result) < _count_titles(schema) - - def test_keeps_defs_referenced_below_the_traversal_cutoff(self): - """A $ref deeper than the traversal walks must still pin its definition. - - The reference scan stops at its depth guard, so past that point it - cannot prove a definition is unused. Dropping one anyway would leave a - dangling $ref — an invalid schema is worse than an unpruned one. - """ - schema: dict[str, Any] = {"$ref": "#/$defs/Leaf"} - for _ in range(60): - schema = {"type": "array", "items": schema} - schema["$defs"] = {"Leaf": {"type": "string"}} - - result = compress_schema(schema) - - assert result["$defs"] == {"Leaf": {"type": "string"}} - - node: Any = result - while isinstance(node.get("items"), dict): - node = node["items"] - assert node == {"$ref": "#/$defs/Leaf"} - def test_preserves_refs_by_default(self): """Test that compress_schema preserves $refs by default.""" schema = { diff --git a/tests/utilities/test_logging.py b/tests/utilities/test_logging.py index c74b6c436..d15b9c355 100644 --- a/tests/utilities/test_logging.py +++ b/tests/utilities/test_logging.py @@ -1,7 +1,4 @@ import logging -from pathlib import Path - -from rich.logging import RichHandler import fastmcp from fastmcp.utilities.logging import configure_logging, get_logger @@ -45,19 +42,6 @@ def test_configure_logging_with_traceback_kwargs(): assert len(logger.handlers) == 2 # One for normal logs, one for tracebacks -def test_configure_logging_suppresses_framework_package_paths(): - configure_logging(enable_rich_tracebacks=True) - - traceback_handler = logging.getLogger("fastmcp").handlers[-1] - assert isinstance(traceback_handler, RichHandler) - suppressed_packages = { - Path(path).name - for path in traceback_handler.tracebacks_suppress - if isinstance(path, str) - } - assert {"fastmcp", "mcp", "pydantic"} <= suppressed_packages - - def test_configure_logging_traceback_defaults_can_be_overridden(): """Test that default traceback settings can be overridden by kwargs.""" configure_logging( @@ -107,6 +91,8 @@ def test_configure_logging_with_rich_enabled(): # Should have two handlers when rich logging is enabled (normal + traceback) assert len(logger.handlers) == 2 # Both should be RichHandler instances + from rich.logging import RichHandler + assert all(isinstance(h, RichHandler) for h in logger.handlers) finally: fastmcp.settings.enable_rich_logging = original_enable_rich diff --git a/tests/utilities/test_skills.py b/tests/utilities/test_skills.py index 90c6f5726..62268ac91 100644 --- a/tests/utilities/test_skills.py +++ b/tests/utilities/test_skills.py @@ -269,53 +269,6 @@ class TestDownloadSkill: downloaded = (result / "SKILL.md").read_text() assert downloaded == original - async def test_writes_text_resources_as_utf8( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - content = "Use the tool — then stop." - manifest = { - "skill": "unicode", - "files": [ - { - "path": "SKILL.md", - "size": len(content.encode("utf-8")), - "hash": "sha256:unicode", - } - ], - } - client = FakeResourceReader( - { - "skill://unicode/_manifest": [ - text_resource("skill://unicode/_manifest", json.dumps(manifest)) - ], - "skill://unicode/SKILL.md": [ - text_resource("skill://unicode/SKILL.md", content) - ], - } - ) - original_write_text = Path.write_text - - def locale_sensitive_write_text( - path: Path, - data: str, - encoding: str | None = None, - errors: str | None = None, - newline: str | None = None, - ) -> int: - return original_write_text( - path, - data, - encoding=encoding or "ascii", - errors=errors, - newline=newline, - ) - - monkeypatch.setattr(Path, "write_text", locale_sensitive_write_text) - - result = await download_skill(cast(Client, client), "unicode", tmp_path) - - assert (result / "SKILL.md").read_text(encoding="utf-8") == content - async def test_raises_if_exists_without_overwrite( self, skills_server: FastMCP, tmp_path: Path ): diff --git a/tests/utilities/test_tests.py b/tests/utilities/test_tests.py index d5fc24c21..6913d1899 100644 --- a/tests/utilities/test_tests.py +++ b/tests/utilities/test_tests.py @@ -48,7 +48,7 @@ class TestHeadlessOAuthCallbackHandler: The OAuth callback handler in HeadlessOAuth parses the redirect Location header. parse_qs without keep_blank_values=True silently drops keys whose - value is empty (e.g. `?state=`), which misrepresents real OAuth callbacks + value is empty (e.g. `?state=`), which mis-models real OAuth callbacks where an empty `state` is distinct from a missing one. """ diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index 5ad8d16f7..3d366aa64 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -282,25 +282,10 @@ class TestAudio: assert audio.data == b"test" assert audio._mime_type == "audio/wav" # Default for raw data - def test_mime_type_from_format(self): - """Test MIME type normalization from audio format.""" - - expected = { - "wav": "audio/wav", - "mp3": "audio/mpeg", - "ogg": "audio/ogg", - "m4a": "audio/mp4", - "flac": "audio/flac", - } - - for fmt, mime in expected.items(): - audio = Audio(data=b"test", format=fmt) - assert audio._mime_type == mime - def test_audio_initialization_with_format(self): """Test audio initialization with a specific format.""" audio = Audio(data=b"test", format="mp3") - assert audio._mime_type == "audio/mpeg" + assert audio._mime_type == "audio/mp3" def test_missing_data_and_path_raises_error(self): """Test that error is raised when neither path nor data is provided.""" @@ -350,7 +335,7 @@ class TestAudio: content = audio.to_audio_content() assert content.type == "audio" - assert content.mime_type == "audio/mpeg" + assert content.mime_type == "audio/mp3" assert content.data == base64.b64encode(test_data).decode() def test_to_audio_content_error(self, monkeypatch): @@ -476,22 +461,6 @@ class TestFile: if isinstance(resource.resource, BlobResourceContents): assert resource.resource.blob == base64.b64encode(test_data).decode() - def test_to_resource_content_with_data_and_name_without_extension(self): - """Test data-backed File URI with a custom name that needs an extension.""" - file = File(data=b"test file data", format="pdf", name="report") - resource = file.to_resource_content() - - assert resource.resource.mime_type == "application/pdf" - assert str(resource.resource.uri) == "file:///report.pdf" - - def test_to_resource_content_with_data_preserves_name_extension(self): - """Test data-backed File URI preserves a custom name with an extension.""" - file = File(data=b"test file data", format="pdf", name="report.pdf") - resource = file.to_resource_content() - - assert resource.resource.mime_type == "application/pdf" - assert str(resource.resource.uri) == "file:///report.pdf" - def test_to_resource_content_with_text_data(self): """Test conversion to ResourceContent with text data (TextResourceContents).""" test_data = b"hello world" diff --git a/uv.lock b/uv.lock index a3d4ce109..a3fe4cc9b 100644 --- a/uv.lock +++ b/uv.lock @@ -4,62 +4,42 @@ requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] [options] -exclude-newer = "2026-07-21T14:39:05.08339Z" +exclude-newer = "2026-07-07T19:26:14.279827Z" exclude-newer-span = "P1W" [options.exclude-newer-package] mcp-types = false -fastmcp = false prefab-ui = false +truststore = false mcp = false -fastmcp-remote = false -fastmcp-slim = false +httpcore2 = false +httpx2 = false [manifest] members = [ "fastmcp", "fastmcp-remote", "fastmcp-slim", - "fastmcp-tasks", ] [[package]] name = "aiofile" version = "3.9.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "caio", marker = "python_full_version < '3.11'" }, + { name = "caio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, ] -[[package]] -name = "aiofile" -version = "3.11.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] -dependencies = [ - { name = "caio", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -80,7 +60,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.117.0" +version = "0.87.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -92,32 +72,32 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/0d/8f71d535edb0d438f023bd825fb65f67c14fa88a2bd6b75f292a58a63de4/anthropic-0.117.0.tar.gz", hash = "sha256:98107f2b76439641e0ae2a1754087534b8f178dbab99d6eb1bc4b7bc8c744496", size = 989933, upload-time = "2026-07-16T19:36:13.07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/8f/3281edf7c35cbac169810e5388eb9b38678c7ea9867c2d331237bd5dff08/anthropic-0.87.0.tar.gz", hash = "sha256:098fef3753cdd3c0daa86f95efb9c8d03a798d45c5170329525bb4653f6702d0", size = 588982, upload-time = "2026-03-31T17:52:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/4c/917d21d6619a4475cdafc6d13a69fdb3b901ddac57e76caca5a25c117b6d/anthropic-0.117.0-py3-none-any.whl", hash = "sha256:451a0a6905f11dff7663d13e4ee5dbf909eb8942b1d049803c7b937a13ac47ec", size = 998327, upload-time = "2026-07-16T19:36:11.225Z" }, + { url = "https://files.pythonhosted.org/packages/0d/02/99bf351933bdea0545a2b6e2d812ed878899e9a95f618351dfa3d0de0e69/anthropic-0.87.0-py3-none-any.whl", hash = "sha256:e2669b86d42c739d3df163f873c51719552e263a3d85179297180fb4fa00a236", size = 472126, upload-time = "2026-03-31T17:52:40.174Z" }, ] [[package]] name = "anyio" -version = "4.14.2" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "asttokens" -version = "3.0.2" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] [[package]] @@ -153,15 +133,15 @@ wheels = [ [[package]] name = "azure-core" -version = "1.41.0" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/83/bbde3faa84ddcb8eb0eca4b3ffb3221252281db4ce351300fe248c5c70b1/azure_core-1.39.0.tar.gz", hash = "sha256:8a90a562998dd44ce84597590fff6249701b98c0e8797c95fcdd695b54c35d74", size = 367531, upload-time = "2026-03-19T01:31:29.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, ] [[package]] @@ -209,7 +189,7 @@ wheels = [ [[package]] name = "black" -version = "26.5.1" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -221,34 +201,34 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, - { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, - { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, - { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, - { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, - { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, - { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, - { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, - { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, - { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, - { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, - { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, - { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, - { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -269,11 +249,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.1.4" +version = "7.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, + { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, ] [[package]] @@ -307,220 +287,210 @@ wheels = [ [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] name = "cffi" -version = "2.1.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +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/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, - { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, - { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, - { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, - { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, - { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, - { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, - { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, - { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, - { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, - { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, - { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, - { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, - { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, - { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, - { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, - { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, - { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, - { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, - { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, - { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, - { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, - { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, - { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, - { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, - { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, + { 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.9" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, - { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, - { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, - { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, - { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, - { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, - { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, - { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, - { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, - { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, - { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, - { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] name = "click" -version = "8.4.2" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -543,100 +513,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.2" +version = "7.13.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, - { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, - { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, - { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, - { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, - { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, - { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, - { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, - { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, - { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, - { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, - { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, - { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, - { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, - { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, - { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, - { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, - { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, - { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, - { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, - { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, - { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, - { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, - { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, - { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, - { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [package.optional-dependencies] @@ -711,7 +696,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.22.1" +version = "4.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -721,18 +706,18 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/47/32d992e829f63aedea5b93360db23c8882c9bbbde094bcf0fff899ea8a3b/cyclopts-4.22.1.tar.gz", hash = "sha256:49cd3779da7113a96ac5c23b151aa61ac9ae1b4b1fe813594d207ca843c97892", size = 193551, upload-time = "2026-07-20T17:38:59.38Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/c4/2ce2ca1451487dc7d59f09334c3fa1182c46cfcf0a2d5f19f9b26d53ac74/cyclopts-4.10.1.tar.gz", hash = "sha256:ad4e4bb90576412d32276b14a76f55d43353753d16217f2c3cd5bdceba7f15a0", size = 166623, upload-time = "2026-03-23T14:43:01.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/89/f2710638cd2f824cb976777e63ef6ed1e83fc56888cf5c9f5a053490b7be/cyclopts-4.22.1-py3-none-any.whl", hash = "sha256:9b614e231075aee9849c0bfd78f7611ab7adf417f16af5b9e42b9ed6e18c17d1", size = 232953, upload-time = "2026-07-20T17:38:58.078Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/2261922126b2e50c601fe22d7ff5194e0a4d50e654836260c0665e24d862/cyclopts-4.10.1-py3-none-any.whl", hash = "sha256:35f37257139380a386d9fe4475e1e7c87ca7795765ef4f31abba579fcfcb6ecd", size = 204331, upload-time = "2026-03-23T14:43:02.625Z" }, ] [[package]] name = "decorator" -version = "5.3.1" +version = "5.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] [[package]] @@ -764,11 +749,20 @@ wheels = [ [[package]] name = "docstring-parser" -version = "0.18.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] @@ -829,7 +823,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.139.2" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -838,9 +832,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [[package]] @@ -870,7 +864,7 @@ openai = [ { name = "fastmcp-slim", extra = ["openai"] }, ] tasks = [ - { name = "fastmcp-tasks" }, + { name = "fastmcp-slim", extra = ["tasks"] }, ] [package.dev-dependencies] @@ -881,7 +875,8 @@ dev = [ { name = "fastmcp-remote" }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "loq" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-sdk" }, @@ -913,7 +908,7 @@ requires-dist = [ { name = "fastmcp-slim", extras = ["code-mode"], marker = "extra == 'code-mode'", editable = "fastmcp_slim" }, { name = "fastmcp-slim", extras = ["gemini"], marker = "extra == 'gemini'", editable = "fastmcp_slim" }, { name = "fastmcp-slim", extras = ["openai"], marker = "extra == 'openai'", editable = "fastmcp_slim" }, - { name = "fastmcp-tasks", marker = "extra == 'tasks'", editable = "fastmcp_tasks" }, + { name = "fastmcp-slim", extras = ["tasks"], marker = "extra == 'tasks'", editable = "fastmcp_slim" }, ] provides-extras = ["anthropic", "apps", "azure", "code-mode", "gemini", "openai", "tasks"] @@ -944,7 +939,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.12.8" }, - { name = "ty", specifier = ">=0.0.59" }, + { name = "ty", specifier = ">=0.0.55" }, ] [[package]] @@ -1030,6 +1025,9 @@ server = [ { name = "watchfiles" }, { name = "websockets" }, ] +tasks = [ + { name = "pydocket" }, +] [package.metadata] requires-dist = [ @@ -1046,14 +1044,14 @@ requires-dist = [ { name = "httpx2", marker = "extra == 'client'", specifier = ">=2.5.0" }, { name = "httpx2", marker = "extra == 'mcp'", specifier = ">=2.5.0" }, { name = "httpx2", marker = "extra == 'server'", specifier = ">=2.5.0" }, - { name = "joserfc", marker = "extra == 'server'", specifier = ">=1.5.0" }, + { name = "joserfc", marker = "extra == 'server'", specifier = ">=1.1.0" }, { name = "jsonref", marker = "extra == 'gemini'", specifier = ">=1.1.0" }, { name = "jsonref", marker = "extra == 'server'", specifier = ">=1.1.0" }, { name = "jsonschema-path", marker = "extra == 'server'", specifier = ">=0.3.4" }, - { name = "mcp", marker = "extra == 'client'", specifier = ">=2.0.0,<3.0.0" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.0.0,<3.0.0" }, - { name = "mcp", marker = "extra == 'server'", specifier = ">=2.0.0,<3.0.0" }, - { name = "mcp-types", specifier = ">=2.0.0,<3.0.0" }, + { name = "mcp", marker = "extra == 'client'", specifier = "==2.0.0b2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = "==2.0.0b2" }, + { name = "mcp", marker = "extra == 'server'", specifier = "==2.0.0b2" }, + { name = "mcp-types", specifier = "==2.0.0b2" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, { name = "openapi-pydantic", marker = "extra == 'server'", specifier = ">=0.5.1" }, { name = "opentelemetry-api", marker = "extra == 'client'", specifier = ">=1.28.0" }, @@ -1065,8 +1063,9 @@ requires-dist = [ { name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], marker = "extra == 'client'", specifier = ">=0.4.4,<0.5.0" }, { name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], marker = "extra == 'server'", specifier = ">=0.4.4,<0.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }, - { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.18" }, + { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.17" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pydocket", marker = "extra == 'tasks'", specifier = ">=0.20.0" }, { name = "pyjwt", marker = "extra == 'azure'", specifier = ">=2.12.0" }, { name = "pyperclip", marker = "extra == 'server'", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, @@ -1082,37 +1081,19 @@ requires-dist = [ { name = "watchfiles", marker = "extra == 'server'", specifier = ">=1.0.0" }, { name = "websockets", marker = "extra == 'server'", specifier = ">=15.0.1" }, ] -provides-extras = ["anthropic", "apps", "azure", "client", "code-mode", "gemini", "mcp", "openai", "server"] - -[[package]] -name = "fastmcp-tasks" -source = { editable = "fastmcp_tasks" } -dependencies = [ - { name = "burner-redis", marker = "sys_platform == 'win32'" }, - { name = "cryptography" }, - { name = "fastmcp-slim", extra = ["server"] }, - { name = "pydocket" }, -] - -[package.metadata] -requires-dist = [ - { name = "burner-redis", marker = "sys_platform == 'win32'", specifier = "<0.1.7" }, - { name = "cryptography", specifier = ">=43.0.0" }, - { name = "fastmcp-slim", extras = ["server"], editable = "fastmcp_slim" }, - { name = "pydocket", specifier = ">=0.20.0" }, -] +provides-extras = ["anthropic", "apps", "azure", "client", "code-mode", "gemini", "mcp", "openai", "server", "tasks"] [[package]] name = "google-auth" -version = "2.55.2" +version = "2.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/b9/e370d86fea3da13ec0256df30323dd26c0cb9c8c85f0c6ec42ac9df0106b/google_auth-2.55.2.tar.gz", hash = "sha256:97ae7790ff740f2bc9db60eb864a7804f4ac19f5f02c38b3d942f2fea6e9b9ae", size = 361414, upload-time = "2026-07-07T18:43:21.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/c6/02eb5a337ac316a4c30c012e747bad5cea36e1a876efecdf80865541f7d8/google_auth-2.55.2-py3-none-any.whl", hash = "sha256:d715f265f2cafc6a5f1bf0dc19870d20e3119f6f6682785a250bce3d03d38a3b", size = 256778, upload-time = "2026-07-07T18:43:19.52Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] [package.optional-dependencies] @@ -1122,7 +1103,7 @@ requests = [ [[package]] name = "google-genai" -version = "2.12.1" +version = "1.69.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1136,91 +1117,91 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/59/9ea84cbeb8f09694564d3b0ee9dd59003551b308d47b61f251415df93982/google_genai-2.12.1.tar.gz", hash = "sha256:78c25217885d63dc430ca7c4526853512b164a25a93a8a0d0af5b85971aa1db0", size = 636710, upload-time = "2026-07-16T16:15:02.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/5e/c0a5e6ff60d18d3f19819a9b1fbd6a1ef2162d025696d8660550739168dc/google_genai-1.69.0.tar.gz", hash = "sha256:5f1a6a478e0c5851506a3d337534bab27b3c33120e27bf9174507ea79dfb8673", size = 519538, upload-time = "2026-03-28T15:33:27.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/b4/1369fb413fc2ba7f78acace5590b6e9990c52ab5d1d166aafaa1ae2c28c8/google_genai-2.12.1-py3-none-any.whl", hash = "sha256:686d5ec39bda345151d3ed1bac3915f01f49138b1ea519af2eb98f11cc55ebc4", size = 1023403, upload-time = "2026-07-16T16:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/ef0586019f54b2ebb36deed7608ccb5efe1377564d2aaea6b1e295d1fadc/google_genai-1.69.0-py3-none-any.whl", hash = "sha256:252e714d724aba74949647b9de511a6a6f7804b3b317ab39ddee9cc2f001cacc", size = 760551, upload-time = "2026-03-28T15:33:24.957Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.75.0" +version = "1.73.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, ] [[package]] name = "griffelib" -version = "2.1.0" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] name = "grpcio" -version = "1.82.1" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/14/5d05bfd85c101cbe44a12d7c1cea9c40698e0438cddf3a70019f735b5a27/grpcio-1.82.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:91859d1cac5f47caec5fc40e9f827500cdb54ce5b36450dc9a65616b5af49c17", size = 6177087, upload-time = "2026-07-08T12:34:06.825Z" }, - { url = "https://files.pythonhosted.org/packages/19/2e/c906f8e6d0b54c0137885fff6f7b5883c6bbc381b44a0ba5ea07d7d1579b/grpcio-1.82.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c80c9741dcef192f669876a81957cf7713b441c2f0c43631350d75fa49321d31", size = 11960907, upload-time = "2026-07-08T12:34:10.583Z" }, - { url = "https://files.pythonhosted.org/packages/de/be/ec4aa76cdf25539b9e960cbb9d5739f892ea6cde58078b5293860c1159d3/grpcio-1.82.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b89cff456796d2f0581783726ad017a2c70aff2d27b0f05504c34e2e417f7560", size = 6754802, upload-time = "2026-07-08T12:34:13.082Z" }, - { url = "https://files.pythonhosted.org/packages/e6/dd/47519c2a8fd9db47ec4493f44bd9f5b0175307e07089b1132e54b7b5b19c/grpcio-1.82.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d6e8a08f7038ba7a77f71e250804e4aba84fe91d22cfc54ff43c07b7529c4728", size = 7484535, upload-time = "2026-07-08T12:34:15.164Z" }, - { url = "https://files.pythonhosted.org/packages/63/99/659711e9689c4dd553bcd4eacff9cb9f458f34b60edf7afb3bbc1b0a58a2/grpcio-1.82.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50fd2fe83426b1b1c6cdc4d72d555223b7dddf8ce07c5bac218b13fc6d684c6f", size = 6919066, upload-time = "2026-07-08T12:34:17.367Z" }, - { url = "https://files.pythonhosted.org/packages/29/39/f2b772356b4f593ffe439795509fcbf675b0ff98211ae8ce2a180f2e559f/grpcio-1.82.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b758540a24d5394a9c578bf9f6126389f474b106ac3d9df1d53de56cb14c9fd9", size = 7525855, upload-time = "2026-07-08T12:34:19.479Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/b28cfffb989a84d8272593498bddd2d68148cce1813ad55189c469b0f1f8/grpcio-1.82.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c4ba4aac238f685743575d9d700003ac16537cce26e7c774993134f530652464", size = 8565122, upload-time = "2026-07-08T12:34:21.951Z" }, - { url = "https://files.pythonhosted.org/packages/97/f9/54956cb0c701190cbc9d7e535c3f84acf0285c6b9ed198a902766e17c3cd/grpcio-1.82.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed6fc621d6f366c88a60f0b971d5afd21d441d9aa561ee688de5b7acdb2cf901", size = 7933872, upload-time = "2026-07-08T12:34:24.539Z" }, - { url = "https://files.pythonhosted.org/packages/76/85/5f9cd1f965bbe4329556a212f178ae0c072b18b446cae05ed32fa8847c53/grpcio-1.82.1-cp310-cp310-win32.whl", hash = "sha256:bd2f45e46fff5b91c10997d0743a987517a7dde67c64c592835c2dcaac66f587", size = 4257373, upload-time = "2026-07-08T12:34:26.566Z" }, - { url = "https://files.pythonhosted.org/packages/93/b0/c4f42f7c69c53d27ed41643421b55908bcbe885b68f5a208135c72917c98/grpcio-1.82.1-cp310-cp310-win_amd64.whl", hash = "sha256:5e171d5f0d6a0af78ea7512783f170a44f80c165259d8773e3a354a7f991f2b5", size = 5006571, upload-time = "2026-07-08T12:34:28.778Z" }, - { url = "https://files.pythonhosted.org/packages/26/5b/e5092af97fa671ca279b3e373251af4bf87d5fbda7dc85f6a616899562a7/grpcio-1.82.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:0ddb18a9a9e1f46692b3567ae4abb3f8d117ce6afea48650f8eca06d8ab5d06f", size = 6181472, upload-time = "2026-07-08T12:34:31.009Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8f/18053a3a2ca03d0c2a1b8cc7271e705007a16aa5dae84bac00935c5b1a7f/grpcio-1.82.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cf855b1af246720f567b0ce5d0724d45dfa4188eecc3296a2a69257b11b9e94b", size = 11970995, upload-time = "2026-07-08T12:34:33.603Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/21b1acb052876ad00959ec4d1b05fe08607d650bcfa282073bb164c2703c/grpcio-1.82.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb30cb13e25bc13cea70ffc69d6d90c49d36ea6c1d4549e6912f70177834cac", size = 6760127, upload-time = "2026-07-08T12:34:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/3e/12/25eef9c245c54f0061317d13a302357fe8ea03bac240b2b02ececcf54da4/grpcio-1.82.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1e822b2774f719c017cbe700b6e47173b6ae290fb84906f52a5a3c2c60b62e1e", size = 7484377, upload-time = "2026-07-08T12:34:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/1a348767eb9d9bd7765dc4fa8a01723d3bb386d67f981ee5c6f9c02b8b1c/grpcio-1.82.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5dafb1ece8ed45dee7c738f166ec82e19673221ed5ab8967f72858a4685345b2", size = 6924269, upload-time = "2026-07-08T12:34:40.583Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b9/3aae7a03d34c86ea27988db859a6087c186f6c3f53f9b551e07afd989bfa/grpcio-1.82.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06503106e7271e0a49fd5a1ac04747f1e47e87d900476db6fe45bc87ee411f4", size = 7531848, upload-time = "2026-07-08T12:34:43.277Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/3c4afa625d0dac9090707966916284c035fc5b2fb3e2c51e156accee6735/grpcio-1.82.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff99bc8cafb6a952201c37b995f425e641c93ffa6e072258525feab57290141d", size = 8568217, upload-time = "2026-07-08T12:34:45.502Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d8489c628e73e20a3d034e7f66912de7b1acb405f01d388f056a88e47924/grpcio-1.82.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:644ae1b94266ac785330f4590a69e52b6a7eb73029043a02209db81c81397d69", size = 7938771, upload-time = "2026-07-08T12:34:48.323Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b7/0a92cfd1658f3a896d4aa12d4efeb7dd4ddfc723725ae22741a5241ea710/grpcio-1.82.1-cp311-cp311-win32.whl", hash = "sha256:e203d2e19d471630084a16c815616f8211dff21c268ab3c5f5bf38417832e074", size = 4256432, upload-time = "2026-07-08T12:34:50.432Z" }, - { url = "https://files.pythonhosted.org/packages/c7/6a/2872c761b025d9ec74386f22a4a7d59c5a5b00ebf718761b33739ffc45de/grpcio-1.82.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d8299c285fe6cc6a1f56badf8d3bc5078c8d20273ee64bafa3783b4bc29a769", size = 5009633, upload-time = "2026-07-08T12:34:52.67Z" }, - { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, - { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, - { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, - { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, - { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, - { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, - { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, - { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, - { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, - { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, - { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, - { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, - { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, - { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, - { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, - { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, - { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, - { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, + { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, + { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, ] [[package]] @@ -1247,15 +1228,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.7.0" +version = "2.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/db/2ad49878b36af4cff7527c1158b083ad6d9350462f1a35685cc3ebfa7c2b/httpcore2-2.6.0.tar.gz", hash = "sha256:95b692b582402ec49b3d84c2343556e4ac4c0962c8b3d39c48d485b9ecc240ab", size = 65592, upload-time = "2026-07-14T10:48:33.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/08f483851a70ef10806e3b84240f2a8f923658035b794f7609795895d9ea/httpcore2-2.6.0-py3-none-any.whl", hash = "sha256:c237a45c7eef885cf032cb9b850d59fcf1fa7e00230307f08aab26486a6ed584", size = 81507, upload-time = "2026-07-14T10:48:31.504Z" }, ] [[package]] @@ -1275,7 +1256,7 @@ wheels = [ [[package]] name = "httpx2" -version = "2.7.0" +version = "2.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1284,9 +1265,9 @@ dependencies = [ { name = "truststore" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/17/1e142bf3c76684232a092e1e4002be07fd3403b1c2dcb15d0012ea300c8f/httpx2-2.6.0.tar.gz", hash = "sha256:5d362fd59562cf2139a60c67bb016587a70b36156a517f176c7cbf1587d1ab22", size = 92736, upload-time = "2026-07-14T10:48:35.12Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/47/86/7d82f7c6aac32433eaf0c914b8bc870ae25759413b9d7d52ea6aa15f2546/httpx2-2.6.0-py3-none-any.whl", hash = "sha256:6cccc3665d6bceb3c1c4f1422ae7e53fda67a853f0135f09b25ce0d4dcac01e3", size = 88541, upload-time = "2026-07-14T10:48:32.681Z" }, ] [[package]] @@ -1300,14 +1281,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "9.0.0" +version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.13'" }, + { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] [[package]] @@ -1321,7 +1302,7 @@ wheels = [ [[package]] name = "inline-snapshot" -version = "0.35.2" +version = "0.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, @@ -1331,9 +1312,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/38a1b9c6323c5dff0ab6c74aa839f1c7d8b7057a861d80ec5bfe834be24e/inline_snapshot-0.35.2.tar.gz", hash = "sha256:6cfd2ea0b52d9cb9beb13c1e73d740ff86f630ba48d9ea6947e54e3ff8494876", size = 2534713, upload-time = "2026-07-16T10:51:58.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/87/62b78b49042c533038ab1bf0931a7b70fdb78d07a11c9bf159be04027df8/inline_snapshot-0.32.5.tar.gz", hash = "sha256:5025074eab5c82a88504975e2655beeb5e96fd57ed2d9ebb38538473748f2065", size = 2626796, upload-time = "2026-03-13T18:35:54.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ea/de85fc264e4c76bda3552eee42904e8b7079fd826902aa5fa044bc5bbc3c/inline_snapshot-0.35.2-py3-none-any.whl", hash = "sha256:91b230b310c95a8c3b91cdbc637e064c7c2f98c13935b64875a7ba9ef174e161", size = 94669, upload-time = "2026-07-16T10:51:57.457Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/73426dd3da75095fd071ce5c1f8e520e879a582ca04df861575c6feb9166/inline_snapshot-0.32.5-py3-none-any.whl", hash = "sha256:ac617c273e811ed5ca15abd8f8dbd3fa268296bb0642ccb1403a5df61ce2e39e", size = 84993, upload-time = "2026-03-13T18:35:52.955Z" }, ] [package.optional-dependencies] @@ -1368,30 +1349,53 @@ wheels = [ [[package]] name = "ipython" -version = "9.15.0" +version = "9.10.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.11.*'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" }, + { name = "jedi", marker = "python_full_version == '3.11.*'" }, + { name = "matplotlib-inline", marker = "python_full_version == '3.11.*'" }, + { name = "pexpect", marker = "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "stack-data", marker = "python_full_version == '3.11.*'" }, + { name = "traitlets", marker = "python_full_version == '3.11.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, +] + +[[package]] +name = "ipython" +version = "9.12.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version == '3.12.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, + { name = "jedi", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "stack-data", marker = "python_full_version >= '3.12'" }, + { name = "traitlets", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, + { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, ] [[package]] @@ -1432,26 +1436,26 @@ wheels = [ [[package]] name = "jaraco-functools" -version = "4.6.0" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, ] [[package]] name = "jedi" -version = "0.20.0" +version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] [[package]] @@ -1465,113 +1469,111 @@ wheels = [ [[package]] name = "jiter" -version = "0.16.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/d8/b959609e44012a42b1f3e5ba98ea3b33c7e41e6d4b77cd8f00fd19b1d3ad/jiter-0.16.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c", size = 310082, upload-time = "2026-06-29T13:02:31.356Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3d/4d7f5667ea0e0548534ba880b84bb3d12924fd133aa83ad6c6c80fca3d76/jiter-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b", size = 315643, upload-time = "2026-06-29T13:02:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/9b/83/bed2dcb5c9f3e1ccfcbc67dda48265fe7d5ad0c9cadda5fe95f6e3b87f94/jiter-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84", size = 341363, upload-time = "2026-06-29T13:02:34.853Z" }, - { url = "https://files.pythonhosted.org/packages/f4/2f/6bb3c3dda668ebc0445689c81a2b0f26a82b10843d67ed9c9b2c3edc177f/jiter-0.16.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c", size = 365483, upload-time = "2026-06-29T13:02:36.295Z" }, - { url = "https://files.pythonhosted.org/packages/92/35/8a045ccb39164e70dcdae696413b661771f148b68b12b175c3a04d901937/jiter-0.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126", size = 461219, upload-time = "2026-06-29T13:02:38.116Z" }, - { url = "https://files.pythonhosted.org/packages/e7/99/22292dbbf0ed0c610cfe5ddc7f3bd67237a412f121318f865196e62a07bd/jiter-0.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c", size = 374905, upload-time = "2026-06-29T13:02:40.357Z" }, - { url = "https://files.pythonhosted.org/packages/29/ac/2f55ccb1f0eeafa6d89d24caf52f6f0944a59290ee199e9ade62177dca42/jiter-0.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de", size = 348320, upload-time = "2026-06-29T13:02:41.923Z" }, - { url = "https://files.pythonhosted.org/packages/50/e3/7d88b9174c40064fabc07c84a9b62e6b10f5644562ec0e0a29392edbe978/jiter-0.16.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244", size = 356519, upload-time = "2026-06-29T13:02:43.436Z" }, - { url = "https://files.pythonhosted.org/packages/27/57/c4a33aeef513a9d5e26e31534e0bcc752d6ea0e54c94ddb7b68bade669c2/jiter-0.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f", size = 394204, upload-time = "2026-06-29T13:02:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/9d/70/c6c23e76ebb3766b111bc399437bbc9f870a76e2a92e10b2a5f561d57372/jiter-0.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131", size = 521477, upload-time = "2026-06-29T13:02:46.724Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d3/0001c8c0c5976af2625bb1cfb1895e8ec693b6589fe4574b8e6fc2c85501/jiter-0.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b", size = 552187, upload-time = "2026-06-29T13:02:48.144Z" }, - { url = "https://files.pythonhosted.org/packages/f6/76/311b718e07e85740e48619c0632b36f7e0b8d113984499e436452ed13a9a/jiter-0.16.0-cp310-cp310-win32.whl", hash = "sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9", size = 206513, upload-time = "2026-06-29T13:02:49.515Z" }, - { url = "https://files.pythonhosted.org/packages/db/7f/ac680eeb0777dc0eb7dc824800ba27880d7f6bc712e362d34ad8ee559f36/jiter-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26", size = 199505, upload-time = "2026-06-29T13:02:50.829Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, - { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, - { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, - { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, - { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, - { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, - { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, - { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, - { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, - { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, - { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, - { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, - { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, - { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, - { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, - { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, - { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, - { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, - { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, - { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, - { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, - { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, - { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, - { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, - { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, - { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, - { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, - { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, - { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, - { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, - { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, - { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, - { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, - { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, - { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, - { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, - { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, - { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, - { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, - { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, - { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, - { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, - { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, - { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, - { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, - { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, - { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, - { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, - { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, - { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, - { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, - { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, + { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, + { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] [[package]] name = "joserfc" -version = "1.7.4" +version = "1.6.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" }, ] [[package]] @@ -1591,8 +1593,7 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1601,17 +1602,16 @@ wheels = [ [[package]] name = "jsonschema-path" -version = "0.5.0" +version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, { name = "pathable" }, { name = "pyyaml" }, { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", size = 15113, upload-time = "2026-03-03T09:56:46.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, + { url = "https://files.pythonhosted.org/packages/04/d5/4e96c44f6c1ea3d812cf5391d81a4f5abaa540abf8d04ecd7f66e0ed11df/jsonschema_path-0.4.5-py3-none-any.whl", hash = "sha256:7d77a2c3f3ec569a40efe5c5f942c44c1af2a6f96fe0866794c9ef5b8f87fd65", size = 19368, upload-time = "2026-03-03T09:56:45.39Z" }, ] [[package]] @@ -1661,31 +1661,31 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.2.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +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/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, + { 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 = "matplotlib-inline" -version = "0.2.2" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] [[package]] name = "mcp" -version = "2.0.0" +version = "2.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1694,6 +1694,7 @@ dependencies = [ { name = "mcp-types" }, { name = "opentelemetry-api" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -1703,22 +1704,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/aa/c5d38e0199304494be6370667d04d93d8681d9bdc864d56678250dbd3f3b/mcp-2.0.0b2.tar.gz", hash = "sha256:0528d0d38ae798fbff251616ec687faaaa8f5309571e0b2bc553c530fa10b8b1", size = 1590650, upload-time = "2026-07-14T16:47:57.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, + { url = "https://files.pythonhosted.org/packages/a7/90/187d6283a304acc6954987992ef17e2515739a649f148dc8b67b302e1bbd/mcp-2.0.0b2-py3-none-any.whl", hash = "sha256:9c50ae5afa08960ab76d50aa3adab3184952d9bea7ef87f4a4a5ba68bdefcf0a", size = 334286, upload-time = "2026-07-14T16:47:54.768Z" }, ] [[package]] name = "mcp-types" -version = "2.0.0" +version = "2.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/21/db529130ac8edd1d844fc322862afeceaf2b7f610a591fa528002b022e07/mcp_types-2.0.0b2.tar.gz", hash = "sha256:094fa7160106819ab39a1586179c3a9f070bfd833d0a6f8fcb30a54a986cc402", size = 65877, upload-time = "2026-07-14T16:47:59.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e1/c466ceacdaa929396d35ad45176398acd0c416fead0970d3ad22618a19d7/mcp_types-2.0.0b2-py3-none-any.whl", hash = "sha256:35c9c33abb90a77dc6ad1daecaa6407c788c2f32d14d52dad8f843c4f008eae2", size = 68944, upload-time = "2026-07-14T16:47:56.493Z" }, ] [[package]] @@ -1732,11 +1733,11 @@ wheels = [ [[package]] name = "more-itertools" -version = "11.1.0" +version = "10.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] [[package]] @@ -1776,7 +1777,7 @@ wheels = [ [[package]] name = "openai" -version = "2.46.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1788,9 +1789,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/ac/f725c4efbda8657d02be684607e5a2e5ce362e4790fdbcbdfb7c15018647/openai-2.46.0.tar.gz", hash = "sha256:0421e0735ac41451cad894af4cddf0435bfbf8cbc538ac0e15b3c062f2ddc06a", size = 1114628, upload-time = "2026-07-17T02:48:06.05Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/7b/206238ebcb50b235942b1c66dba4974776f2057402a8d91c399be587d66a/openai-2.46.0-py3-none-any.whl", hash = "sha256:672381db55efb3a1e2610f29304c130cccdd0b319bace4d492b2443cb64c1e7c", size = 1637556, upload-time = "2026-07-17T02:48:03.695Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, ] [[package]] @@ -1807,31 +1808,32 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.44.0" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.44.0" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.44.0" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -1842,84 +1844,84 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, + { url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.44.0" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.44.0" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.65b0" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] [[package]] name = "packaging" -version = "26.2" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "parso" -version = "0.8.7" +version = "0.8.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, ] [[package]] name = "pathable" -version = "0.6.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, ] [[package]] name = "pathspec" -version = "1.1.1" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] @@ -1949,11 +1951,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.0" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] [[package]] @@ -1967,49 +1969,49 @@ wheels = [ [[package]] name = "prefab-ui" -version = "0.20.2" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cyclopts" }, { name = "pydantic" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/99/4e61eb3d3f8b09bdaa28bda3c99971264555f486a485333cb8e6f56c7d8c/prefab_ui-0.20.2.tar.gz", hash = "sha256:4ac17ebf8ec1c5a918a188625837fc608157907430a59c4feb8adac80e01262b", size = 4118979, upload-time = "2026-06-03T02:13:49.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/a3/25fe72b9887d9c2daa0ec5e79a7971a67aad31a6f71d634e23da662343ad/prefab_ui-0.18.0.tar.gz", hash = "sha256:f72e241f52f4720baac670f8527c773e1c1f4b558bce4f77097441eecbb51b9e", size = 3998186, upload-time = "2026-03-30T01:13:33.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/d0/59b697dd5a44e632fcc4aba42ff5f88224df672ffea1f5e9a5a9e9e50698/prefab_ui-0.20.2-py3-none-any.whl", hash = "sha256:861d4914e4d9120996b4d5c6753788beeca433754d7bb3cfbd13f9dba7ea8e85", size = 1852274, upload-time = "2026-06-03T02:13:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/c0/dd/28be02a264c59d64086122c8b0f9fa99fc52e040682358e5e08219846961/prefab_ui-0.18.0-py3-none-any.whl", hash = "sha256:c9d01bd423b0d5bf103d9a0e6cfac135bd973d416297c32a5bbccc182161cace", size = 1824803, upload-time = "2026-03-30T01:13:31.243Z" }, ] [[package]] name = "prek" -version = "0.4.10" +version = "0.3.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/54/edc21e275f9fa3540d4d98cf349c2de11621d6729cc401bb7aedf563609e/prek-0.4.10.tar.gz", hash = "sha256:db3122f4e780eb4587635e6a83df881caf2dbb1eb7799d1cca51158216d6f33b", size = 502565, upload-time = "2026-07-16T10:13:00.788Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/e7/5a63528ba7b95b64f38db3e253aed49ee8e5e8ba16589889d2b7f809edb7/prek-0.4.10-py3-none-linux_armv6l.whl", hash = "sha256:023f302741d79301346c3088ba43a9592aff0ecdbe5ddc3019fa9b1183319c5e", size = 5694609, upload-time = "2026-07-16T10:12:26.352Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ef/ee9e6bf9a5ce242e9e4e66ac4e2e9042a0f6fd9f367cee18ad404456e93d/prek-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:72adc707e16f97564bbae08d22b222ac3bb2491f8fbfb5a0754f80d472c28a71", size = 6044037, upload-time = "2026-07-16T10:12:28.539Z" }, - { url = "https://files.pythonhosted.org/packages/68/7e/da08cc39e5348ccb9234e63a21ee56861f72e8497d6a78f0db1ccae6515d/prek-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:04c9321957e1b32e1fc7cf60bb4f90bba3761f8659d5551ed04f96e25596de49", size = 5535983, upload-time = "2026-07-16T10:12:30.691Z" }, - { url = "https://files.pythonhosted.org/packages/30/c6/0486a35bb687a9beac7a5810bd1104c6da56d469b30b1eeaeefd03c99da2/prek-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e66ccf6c5e4ebadd05cd98cb338d7f553e4d27aa243cf91279c5a569b3cdccc7", size = 5862085, upload-time = "2026-07-16T10:12:33.042Z" }, - { url = "https://files.pythonhosted.org/packages/52/39/277fe17ae1f121e532e3942456f5a6d01ddacfbc550e481dcb359be7a1b0/prek-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63f9061d75a50ef0ca92c4b596ad352937a845df80758244950e513b27e9e18f", size = 5605697, upload-time = "2026-07-16T10:12:35.498Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a1/08354af3e000f2656fad086690d834eab6c04631ff41313a219ea6232199/prek-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c2ff7110e4bfaafbbab13c2893a337081aca61ed797f14b6b224d2ea9741eef", size = 6034111, upload-time = "2026-07-16T10:12:37.545Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/4702396c8d486132e5ce009ab56a0b37f50cb6866830d371f2617b7bdfdc/prek-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b696a05542e79aa27bcce68d1792e77f4fe6f9c6b012b34d74d62f964f3c72d", size = 6787203, upload-time = "2026-07-16T10:12:40.031Z" }, - { url = "https://files.pythonhosted.org/packages/90/29/b5d5d6fb87ebd64b37471e3e79761de9983f85e14d69c522efe7af6620ce/prek-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:431b44d6054e72815b4b05e1173596dfd02a7f7461211d40a2e3117e414642ad", size = 6261333, upload-time = "2026-07-16T10:12:42.216Z" }, - { url = "https://files.pythonhosted.org/packages/94/d6/54ba696d19f7efdc184093353cce713a850aef9c3556e23faeecafa22e94/prek-0.4.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ccbd2b4fd1df790087ba18b4506f680471922a5f13714f19801568434a040dee", size = 5867761, upload-time = "2026-07-16T10:12:44.329Z" }, - { url = "https://files.pythonhosted.org/packages/be/7d/3975098aa2baaabfc10f99f9fcf78045c4f10851beed8e9812b6a2688eab/prek-0.4.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:479e7480b447191aa5c6ed67e80f081d0f5ee4e878b140f4d2cee44165395f1c", size = 5714412, upload-time = "2026-07-16T10:12:46.297Z" }, - { url = "https://files.pythonhosted.org/packages/97/c0/3e0aac190fe95fdef98526343559b61d4d9fd54444c8c9137ba02412afe1/prek-0.4.10-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0bb7451025cbd2b68e480a13cf665d7a5c87c8b87bf18549a78985c17df817ed", size = 5578145, upload-time = "2026-07-16T10:12:48.261Z" }, - { url = "https://files.pythonhosted.org/packages/d7/44/7b26035534204b8b8a9d5e625479201e616413d287262f557cb32e1f8d77/prek-0.4.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4fb047e5776676805794574b2d7b178cb3ab536793aadf172419fcda56b34a57", size = 5889245, upload-time = "2026-07-16T10:12:50.818Z" }, - { url = "https://files.pythonhosted.org/packages/7e/6c/178a9d768876b4211a1bf63907fe308ae02d173639bcf41cea3c5eed35c1/prek-0.4.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:08318818d19caf79643babb89f872c92fda134a622b4731df1d6ed61e29d2d26", size = 6372849, upload-time = "2026-07-16T10:12:52.952Z" }, - { url = "https://files.pythonhosted.org/packages/4d/84/d5f5ac8193602883f9dd1d675d9d4084e34fbe3ed2ef50a0c336d8a53d8f/prek-0.4.10-py3-none-win32.whl", hash = "sha256:092872714dcde480a662bbdd98b980b248c2d3e10543d4d53a3a58cc9e5b35b0", size = 5413005, upload-time = "2026-07-16T10:12:55.113Z" }, - { url = "https://files.pythonhosted.org/packages/41/63/9e648fda10bc02c9b6ba305f93b6a6e4fd37d23d13a269a9d2d6bb44eaa1/prek-0.4.10-py3-none-win_amd64.whl", hash = "sha256:3d323a18d0f8c50e474a8fa29fb93bd2db680116d8afb19b76e72ad4667f58e6", size = 5799075, upload-time = "2026-07-16T10:12:56.963Z" }, - { url = "https://files.pythonhosted.org/packages/22/74/b34d8c80cec8dccc7b922c75b9dca62b18b603b5ed2eea93c9d7c2928d2d/prek-0.4.10-py3-none-win_arm64.whl", hash = "sha256:5e93865ef96756c4a26f37ece04ad514abbc19ae6a23ed1a507b6314e6a0d2fb", size = 5563955, upload-time = "2026-07-16T10:12:59.07Z" }, + { url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" }, + { url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" }, + { url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" }, + { url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" }, + { url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" }, + { url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" }, ] [[package]] name = "prometheus-client" -version = "0.25.0" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, ] [[package]] @@ -2026,17 +2028,17 @@ wheels = [ [[package]] name = "protobuf" -version = "7.35.1" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, - { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, - { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -2087,21 +2089,20 @@ wheels = [ [[package]] name = "py-key-value-aio" -version = "0.4.5" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, ] [package.optional-dependencies] filetree = [ - { name = "aiofile", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "aiofile" }, { name = "anyio" }, ] keyring = [ @@ -2116,11 +2117,11 @@ redis = [ [[package]] name = "pyasn1" -version = "0.6.4" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -2146,7 +2147,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.4" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2154,9 +2155,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [package.optional-dependencies] @@ -2166,189 +2167,191 @@ email = [ [[package]] name = "pydantic-core" -version = "2.46.4" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] name = "pydantic-monty" -version = "0.0.18" +version = "0.0.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/5b/bb6a8bfdf13eb9808c966bdac064a40ce9ac881ec6d64dba3e055888f22b/pydantic_monty-0.0.18.tar.gz", hash = "sha256:c43794c7c4664fa1403d4841459d0e23f01b4f552283db638f5b40ced4dac6a1", size = 1197105, upload-time = "2026-05-29T08:31:41.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/f8/431ba0b79d02922811392c4e3d283d6508f7052ceaa1936cc34878703ecc/pydantic_monty-0.0.17.tar.gz", hash = "sha256:9c4904a8fbc63282793f3afd2d180124494c7fc371783f365e5691c9586360af", size = 1007724, upload-time = "2026-04-22T20:13:48.915Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/74/36d50926a7b53b85723960fad50b34b5fc8da79cc8f6091a1f1b44a02b79/pydantic_monty-0.0.18-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:857b62bfc6f06cd9853d4fc51011391e0431187fe9d08034ae24eafcb797c60a", size = 8464519, upload-time = "2026-05-29T08:30:49.301Z" }, - { url = "https://files.pythonhosted.org/packages/28/7b/941e3c9c4816864a2c260df63d3be36c523022732154d2853e5376fcf1e1/pydantic_monty-0.0.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65918fac0835109de6f725069d0aa35b7454c26809634d5344d7b26686754381", size = 8719689, upload-time = "2026-05-29T08:30:27.115Z" }, - { url = "https://files.pythonhosted.org/packages/31/20/84cfdf92732651e68aa52d846a22ae573294241b4aa75ae84c0b3d2782b0/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c5bee11eecbadf03b2e764feb11fdea12a6b176bf071bb2fa922a23a704a83b4", size = 9042115, upload-time = "2026-05-29T08:29:18.039Z" }, - { url = "https://files.pythonhosted.org/packages/23/dc/e3dcdef2d0dc09751ed054c69c2363e05d94c985994197ce5748b22b8799/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de65d5a8c7ba74794d7f50dfa0b36014931abe2a5abe1a48778afc1bb7dd5d60", size = 8171553, upload-time = "2026-05-29T08:30:51.772Z" }, - { url = "https://files.pythonhosted.org/packages/61/93/45d2b8867f74ddff0a45b96e78d1ff5bdd4bfcd68f6fd622009096b4324c/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8694f0897d611d6f81901eee31d5c73c6d677cd50efe95368b40e1dc1d034e8a", size = 8586169, upload-time = "2026-05-29T08:29:58.806Z" }, - { url = "https://files.pythonhosted.org/packages/06/2c/e46629bf65a4017e905db9b87158253869d329cb884604be78e74c0e3d88/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dcd3286f6b74a959acd32cdb4c3f0a423f91ff6d7775a08315091766f74a76dc", size = 9181554, upload-time = "2026-05-29T08:31:03.712Z" }, - { url = "https://files.pythonhosted.org/packages/20/f6/91af3acf83fe6b156134e90e7739ff167247d7a48aa53735b3b6a050a335/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa2cc2dda0c7a271c6b0792ce7e60dd0bc5114263b83dccb147c6d0c88d28614", size = 9286056, upload-time = "2026-05-29T08:30:17.643Z" }, - { url = "https://files.pythonhosted.org/packages/f0/71/1b008c633a4767e518e4aebfd79eb1c2c20259282853b6967373d70ca0f9/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e87e5953fe1ad15f9e67c5dc590ae240889da28bc84c344e255579d4f33281f5", size = 9266143, upload-time = "2026-05-29T08:30:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/6b/c5/d2b44995729c884f682e499fea134f7b19883b3414c077431d80dc222802/pydantic_monty-0.0.18-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:83b82b7c235943081b31eb9a4c8a4af961640cfbc5b7d3a97dda1bf3efd83cff", size = 8350637, upload-time = "2026-05-29T08:30:20.061Z" }, - { url = "https://files.pythonhosted.org/packages/6e/7d/8326aca20b563cf656a2d7e52fca1ec98c9b2cde67eba06ecebffb5b73f7/pydantic_monty-0.0.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0526e5222cbb4cd0a253f49bfcf851dc87984b39d2a5e4eb041d8ce7d1b6987a", size = 8900794, upload-time = "2026-05-29T08:31:24.604Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a6/f62f187a1327ae3bf101de44439b62508da7895ca63c38295115c12a1006/pydantic_monty-0.0.18-cp310-cp310-win32.whl", hash = "sha256:668a4502e9bd67c7bb5d2c4c9d153e9f798d4f5f452845b9a72aaa1f8ce86ab8", size = 8280979, upload-time = "2026-05-29T08:30:47.128Z" }, - { url = "https://files.pythonhosted.org/packages/8c/62/455b679f3b5c00caf362b2388d8a191889f2496f834500989be404175997/pydantic_monty-0.0.18-cp310-cp310-win_amd64.whl", hash = "sha256:12c2ac68f2a12ac68bcd51beb1bf6c2e5fd81061584fd5a826d3454fc9220e36", size = 9482422, upload-time = "2026-05-29T08:31:10.421Z" }, - { url = "https://files.pythonhosted.org/packages/8f/50/06720fb35b73993aa9964403eff1ab35b1d7bd0db1b1ee0633e19311e254/pydantic_monty-0.0.18-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5140382a6ea68778c76f04ccb91fdbfd1a77b8cae3a89534e23a0e5afaf21e75", size = 8464367, upload-time = "2026-05-29T08:29:46.855Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8e/b3946ee663349fb35f9dceddf1aed394b8e5df1d8767b840844db9cee515/pydantic_monty-0.0.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421d1b7956e06a22dc13fe6a34bebf3a1bdde8cf78616eded018f7a9ca746295", size = 8718281, upload-time = "2026-05-29T08:31:06.121Z" }, - { url = "https://files.pythonhosted.org/packages/36/3f/9fb2e8d0ed660d0e5b281316be0c1cb1a023b156c02a8dc8a2c3ec007af7/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6609de4408ad54387ecd0b3eedce796497ee72c6ec888074519afcd4f6959a81", size = 9041289, upload-time = "2026-05-29T08:29:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/d3/5e/cb242ba7bd63985eee94f0dff8864002bb5ded2da7190e73786ddcd5b4e8/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2b37890d8a948606be184bd6e3fa4e445d26e3c7329c6a451a271bfc470f24", size = 8170676, upload-time = "2026-05-29T08:29:38.358Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8d/d144775ea57b813e97aef9edaf5f867fb82960597af517125e1b513c983c/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:19b86e4fc4b73c2c925906bbc31488b9e8b99b54101f8ea7bbdccfd60ded38f0", size = 8585337, upload-time = "2026-05-29T08:31:27.206Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/6291a4871fbdb8dfa66d1b1f2406c11066757caa1c53092320e5d11ea49d/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4de83f38b3658152697c524ea87ce307a13701d807801c9be27870e837829a02", size = 9181594, upload-time = "2026-05-29T08:31:36.736Z" }, - { url = "https://files.pythonhosted.org/packages/d7/00/28879cee77e24f70c756c4603b4a21b013e601b7821bd07e06cf6718b75a/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3aaa4fb7af8fd84f42df5e1e43d7ff3eae7d81314580462c8ca716e7e6e361", size = 9285193, upload-time = "2026-05-29T08:30:58.727Z" }, - { url = "https://files.pythonhosted.org/packages/9f/07/52dece571ef47085d2f1053df4c1be8d5b42d8735da4797f5f79d650f81f/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d3dd72c195eca243b08c5d68b1f513124feaf22acb87b73cd8bfcdc3f6b4bb7", size = 9264997, upload-time = "2026-05-29T08:29:49.51Z" }, - { url = "https://files.pythonhosted.org/packages/38/12/b010315be2927c5be43d3a4036cd6857d9981d5116efda5e40540f43a014/pydantic_monty-0.0.18-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b0409a5f314af54f704d73cd97fe2a0cc8ef2635952bf57c5879b9313281614", size = 8350432, upload-time = "2026-05-29T08:30:10.984Z" }, - { url = "https://files.pythonhosted.org/packages/84/8b/9674a90269dc0f1a080e606cba642b256e138e7fcee1a3a0b55969946f83/pydantic_monty-0.0.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:790f169bb5700e3ab24a8d44fd7016d915c701e27bcd2feabe0de917306bbff0", size = 8900736, upload-time = "2026-05-29T08:29:42.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/6a/07351c22208814c466d9a26bab03642727e9f5570562cbd4fbde837e4644/pydantic_monty-0.0.18-cp311-cp311-win32.whl", hash = "sha256:3682f3bd67ef92ecd78a3f5f4efcd7659ba643aaca45412601a92691d6440250", size = 8280476, upload-time = "2026-05-29T08:31:17.814Z" }, - { url = "https://files.pythonhosted.org/packages/eb/de/937dcc0e828d324f037a5004f97c8ff245158fe735107023a10d8f672e32/pydantic_monty-0.0.18-cp311-cp311-win_amd64.whl", hash = "sha256:eecdf1175542ac2fd3f6a203c7744145be4e73e08755c6de1d35253dc6a872e7", size = 9480905, upload-time = "2026-05-29T08:30:15.287Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d1/307df5ac3a694acc5922f00fc7ce96357ad4afaa41bbfeec0b8379bed6ec/pydantic_monty-0.0.18-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1030bd49b813e67aedf4f7bb3dd4cc9edaa203554b3b8fe11eeab6d61139229f", size = 8462571, upload-time = "2026-05-29T08:29:21.081Z" }, - { url = "https://files.pythonhosted.org/packages/55/83/8ccf04b2f9642153702c6eb22d0a0abad57014fd85879ab1f6341b5a1946/pydantic_monty-0.0.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2988d3e511131680d9de60647bfe5c697b1e4e4cad474fecf1451c53314e8520", size = 8688756, upload-time = "2026-05-29T08:30:24.677Z" }, - { url = "https://files.pythonhosted.org/packages/81/84/e3ce3294636b92a5eb238273026dd2825d97deac44f76e901990a4eeb306/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7d8d0f42162cb40da05f32d50d9d8d74411b3d4f1182117c8365f18457442c0d", size = 9046635, upload-time = "2026-05-29T08:30:06.38Z" }, - { url = "https://files.pythonhosted.org/packages/de/b8/c7881620a812850772ae0924863d1399cbecb3e4c8c455a9c7a9c20b06f8/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c688dc7c7b28a2f389a61217bae1d50658e28f960abe33a254328cadc8a17a", size = 8171342, upload-time = "2026-05-29T08:29:44.773Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ea/6d10ea1657e303295a75a3854f6dd6b378cbd501dcd1782844107b932acd/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f469293f5a776231b9617787a5dd6c58048c6568833ee6848338be6391f15449", size = 8591152, upload-time = "2026-05-29T08:31:29.944Z" }, - { url = "https://files.pythonhosted.org/packages/93/fb/ab85c4676ccffd0f3b7f509a4c8b396b07c7860577def2f58a22b3fe8aef/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6e0cd4991947b8a47210985836f94c14139bc3ea06253d2f38d0d752b165517", size = 9183064, upload-time = "2026-05-29T08:31:12.776Z" }, - { url = "https://files.pythonhosted.org/packages/5c/12/11292178b487052f9e0a1ea7b3d17e1e3bfcba598fefce8cb9ed8712021e/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58b4b96863abbc0ffa5baf64779b3fbb6376cc763488b027ecaddb5052d5ff17", size = 9285440, upload-time = "2026-05-29T08:29:35.642Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/7afb8dde4414d84c042f2cc1b0870a7351cae2e4fbf3fef89b3aa683eca9/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1208bd5976c1254b2705559836511c86ea3cc51d9c6f688b1e3e22984715dbc", size = 9233438, upload-time = "2026-05-29T08:31:39.177Z" }, - { url = "https://files.pythonhosted.org/packages/5f/46/89124cf146725e354b44685b477da6b0b5dc07a8a3af2aec309e88c55405/pydantic_monty-0.0.18-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:977168253d8f6b49bb64f128d02c4b62ee8b435fd62876268377e1f2b00cc00f", size = 8351900, upload-time = "2026-05-29T08:31:08.348Z" }, - { url = "https://files.pythonhosted.org/packages/00/c5/dda512f5a9c68242faea368844aacefb54c2a13f9b40bee5ab48ccdc78c5/pydantic_monty-0.0.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c21319a091dc1ff1fccb8647dae5bb543b3f528c556319ed15c7992dfa9b5e87", size = 8901559, upload-time = "2026-05-29T08:29:26.047Z" }, - { url = "https://files.pythonhosted.org/packages/45/97/496655362d4bb6e74ff791cf40be6a502e794c296f0089912783325075a7/pydantic_monty-0.0.18-cp312-cp312-win32.whl", hash = "sha256:220fe77920af9033ae644887e747b68567df630b1a8afa39b0a830d84a3438b5", size = 8277428, upload-time = "2026-05-29T08:30:35.322Z" }, - { url = "https://files.pythonhosted.org/packages/cc/24/2913a50a9afbce681629408814ae94929589bed9aa347b176caf17842957/pydantic_monty-0.0.18-cp312-cp312-win_amd64.whl", hash = "sha256:f965a62993bd3fe7be94f99c86349d61d987b3d8cc07fb729d7d8af87c7d481d", size = 9453897, upload-time = "2026-05-29T08:31:15.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/86/5f1eb8b0743ba65821aa37285f131478672b2832baa08386e931c9e71969/pydantic_monty-0.0.18-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:765865634c2075ec816515db22acf0c71e42d25dcbf66638dc063d95c7d1a858", size = 8473615, upload-time = "2026-05-29T08:30:22.027Z" }, - { url = "https://files.pythonhosted.org/packages/c6/9c/7628423f955efb669d2cc1d3a8909bf8271b543ce27036e18229ad0e51e8/pydantic_monty-0.0.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d47976a18e3e3da0e86f8cf6068fc8a125930422dc10d3d3bf0b5410a9e9282e", size = 8689116, upload-time = "2026-05-29T08:29:30.906Z" }, - { url = "https://files.pythonhosted.org/packages/4c/dd/ec6cbbe997205063c679ef17220a48fcb0a4c319fc336ca81a3c7c248c6d/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c6bc7a776d9d97b899054263c0f0c7316523571da02b4c2a6d2ecd4793482e0", size = 9045884, upload-time = "2026-05-29T08:30:53.831Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9c/51f8ffa4340bc1986eb9240b0756724f5fdf3c463d6d66c8cc8450e1446d/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb84fe51e3f6e00a0cc9628e0acf5904d982c8cc85d4db42b7532d071602f703", size = 8178458, upload-time = "2026-05-29T08:30:09.015Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ec/7eb84aeb86631571f9acffc91552217dc2b524b00db37b8d10517df467d1/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a2e86f3ba67b094d498bc8b071d5e3b8b034bb9f3006216a357c5f71d49d6132", size = 8591295, upload-time = "2026-05-29T08:29:23.357Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/d5210208fa116593bd81789e2e5abb6222d38087c9c1879e18f7e7620275/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb8a412aa0336d4e0334a4e05a54b391d91fa334020bd295a280285ba17ab5ca", size = 9184647, upload-time = "2026-05-29T08:29:51.852Z" }, - { url = "https://files.pythonhosted.org/packages/ed/74/4d95c8f65072964c4cb798dbe87d2e1c1349607ab5905874bfa8a0b94de1/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1056ce3acef60ab880314caf97775c5ea41b30f9306d0dd28cefeffd42dda366", size = 9291637, upload-time = "2026-05-29T08:31:19.966Z" }, - { url = "https://files.pythonhosted.org/packages/d0/40/5817780313a3e089ca6f860fbdc836d3aa33790eb72c2e8fe2edc877820e/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9593caa45b68fd07ac67bea9974effbe2a1c5453d8a106b913596b0dff6d8471", size = 9233863, upload-time = "2026-05-29T08:31:42.838Z" }, - { url = "https://files.pythonhosted.org/packages/b3/55/f77565c5797502c7ba995dc23a26759cb33023590f9f2926bc4e8ab87afe/pydantic_monty-0.0.18-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e15e18ed27a17ee607ad3bcbf82f25a9ec4d496ff493ff64cdabb83ca2174cec", size = 8358264, upload-time = "2026-05-29T08:31:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1f/c700eb800868d1be4078a99cb00e23fb7e5d8760c8e83b729bba27b5bf92/pydantic_monty-0.0.18-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:96d75a418d96640ff0c7f354a78fc4470a2d0f40ba69e886c2f32756d594d9a0", size = 8906664, upload-time = "2026-05-29T08:30:04.055Z" }, - { url = "https://files.pythonhosted.org/packages/7f/52/1b4599d5a6dccc65d46956431cfdf5a46df4a18005a93ffec4aab56a47b8/pydantic_monty-0.0.18-cp313-cp313-win32.whl", hash = "sha256:5cd5ff08e6749b3a4a2192856861b36feee8575e2cf81bd6bd1d8b4b39ac630e", size = 8276949, upload-time = "2026-05-29T08:30:12.968Z" }, - { url = "https://files.pythonhosted.org/packages/8e/eb/54c9011e2ef5e1358512ea23bb4a862b4f8fdda2d2f951a58854ff55ee3e/pydantic_monty-0.0.18-cp313-cp313-win_amd64.whl", hash = "sha256:52ce98be1e5bf76974597234ec857b7a6ef99374a036860cd2e2e1bd75c18f1e", size = 9453909, upload-time = "2026-05-29T08:31:01.275Z" }, - { url = "https://files.pythonhosted.org/packages/9a/04/e6462c2d4097189fc4af62b84273d6ef0a69473cbdf1c7f158d8cec25c11/pydantic_monty-0.0.18-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8c38add825895ecfde75f3272126f07dd94f2db08440f165e76d7e321feec8da", size = 8473729, upload-time = "2026-05-29T08:30:32.648Z" }, - { url = "https://files.pythonhosted.org/packages/65/ff/6aca0ddd5c074b2757dd992b38e57f5e6b21ec0631007ec2d1b4dcbd3bff/pydantic_monty-0.0.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:186eaa80945c4a5bb19beba54471d423bffcf5daf64f93029b2d5df1939e201f", size = 8702897, upload-time = "2026-05-29T08:29:56.669Z" }, - { url = "https://files.pythonhosted.org/packages/29/37/d56705a23d7c5ff5112f5f82d56e70a9073a46d6ebfdbce09bcb31a52921/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c7a568fd6db2389743d0d28f355273c0d6d2009c5252c0971dcb1e5629e60d4f", size = 9046049, upload-time = "2026-05-29T08:30:56.314Z" }, - { url = "https://files.pythonhosted.org/packages/c6/00/82a6ddb1ca7bf2b1ef4b1751d960671324f3a0a498fc80d335f3f0962176/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:324443ea73eb70bfd57b34e554278f52501592c4580edc6b9708b0d3d6a42f44", size = 8178495, upload-time = "2026-05-29T08:31:34.62Z" }, - { url = "https://files.pythonhosted.org/packages/71/9b/1a1aab97a113d718d6e6db9a7e5ad2fb9fd9cde8623d4885188983fa349b/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:936438027363474eecc5573eb635d1c5f3bf061ad02334d5b545a132fbb76e45", size = 8593356, upload-time = "2026-05-29T08:30:37.618Z" }, - { url = "https://files.pythonhosted.org/packages/65/89/0f5212ccae4c29fa85c86dea553e45cf4729f94eba47187c747d44f7c890/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f700d2c7139f44ac29f51301c835a23e6c53402984cb23f3e3214e47df7ddaa3", size = 9184371, upload-time = "2026-05-29T08:29:28.574Z" }, - { url = "https://files.pythonhosted.org/packages/26/1a/a2f3f0016a1326ef50d732f53bd8f447b3535fdb59a40287e77d0914935f/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10abc11ae00d712b866b2a64e6a0f34c6a7d5f99903228f4699eeb4ced50299a", size = 9292432, upload-time = "2026-05-29T08:30:30.051Z" }, - { url = "https://files.pythonhosted.org/packages/78/55/8bc4f8924c8bfd366b2c524a19083f86bb457c61f56c47e4ae4bd607536e/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8183aa8e2420aa4c1924cc05b87c8143f953b7c173f240cb7cf20e0b3f865cdb", size = 9247001, upload-time = "2026-05-29T08:30:40.061Z" }, - { url = "https://files.pythonhosted.org/packages/5c/5e/f6ae7d18cfc058f4df49765420800dd5d7de3adbba6be864a8bc919847d2/pydantic_monty-0.0.18-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:03fccf00fd925b616e0b7ce59c354f3fa1e50eb2d511391e260e28793b5d3b0c", size = 8357641, upload-time = "2026-05-29T08:29:40.372Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d3/166961ca42ad855b7a2dd50d494be26ff0222b21b68750081962fb4568f9/pydantic_monty-0.0.18-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:d9dc4185bad6ca7f38d2d71b9d8ed2d68e48e4c4f0ccc89cd0188c08469bda7d", size = 8905773, upload-time = "2026-05-29T08:30:43.535Z" }, - { url = "https://files.pythonhosted.org/packages/df/88/d8bd8e82ca624ca6bebe9ebfb6cfc461214303158ba735751a6c2277043e/pydantic_monty-0.0.18-cp314-cp314-win32.whl", hash = "sha256:4840805ecfe5a38c07126f02181d907c687ca765a73aaabc2b128184390a2c52", size = 8277373, upload-time = "2026-05-29T08:31:22.247Z" }, - { url = "https://files.pythonhosted.org/packages/b6/0e/c395b22ddc32c746d7e2d271dd18bb585289576bb67483435e25643b7ecd/pydantic_monty-0.0.18-cp314-cp314-win_amd64.whl", hash = "sha256:83b6e2b73b0fa60c5641ecb6e8b588840023163ca6ab8b3e5da7ad088390ee7c", size = 9467375, upload-time = "2026-05-29T08:29:54.227Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/8105bc0b3acb42f6cb48a29669a5e21316bc05e3e9b6fab64cf94b483712/pydantic_monty-0.0.17-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3c3b6c026d8a0437eeb4d6b2d908be75e2715e0555b9a13f076b7e9ba9bbae19", size = 7344730, upload-time = "2026-04-22T20:13:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/7281cdb37481c4252292b63bebf737c87d0fd463f3174499608607de0907/pydantic_monty-0.0.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c80b4d34437abd209c042f81f8ecea81a097022fb9b01431ab859b877edfbc4d", size = 7334937, upload-time = "2026-04-22T20:15:06.923Z" }, + { url = "https://files.pythonhosted.org/packages/a5/68/0bf7c0c627a56d8653b42888a3c1fc33cd33d2532ec456d9358275d7c792/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:beecc1f7e5b10db40d7b2b24a68166a36514289a2402bfef370a7984e90a2ab8", size = 7864543, upload-time = "2026-04-22T20:14:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/09/9b/5a6f006541fd3bdc64b6dfbbaeabfb2244c89a22d7077a1fc92ec497c03e/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64ea7babdcc9fba93089fa52589b6d0549f755e37500f6cf4aeaeb8e56328a3e", size = 7138764, upload-time = "2026-04-22T20:15:30.516Z" }, + { url = "https://files.pythonhosted.org/packages/01/cc/59cca979bd427d166df8c827fba9e794c4a5c08943e225a22adf9854a78f/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a7fe77a191205becb622eaecb075e8bcbbbe4dac20a916d9c58ce6d59a22a8da", size = 7444006, upload-time = "2026-04-22T20:15:23.386Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c5/d027170fb33fcbc038febb76dfd2d9047f5194a250ea608e3ed8e5ec28d4/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2cdbefc180cc83c8b8415aaf95b9099bb2cb15261f40ebe2c92f13e7d52439a4", size = 7967564, upload-time = "2026-04-22T20:14:57.315Z" }, + { url = "https://files.pythonhosted.org/packages/3e/01/ac0d4bc1ff00acfac14b7cb2ee322d08778c206cd57f43da8206a2f6ce78/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:575ce5aa31db18bbbf6275f00e9b0c005ca393bfb73a2f306a577ad490ec2d98", size = 8199021, upload-time = "2026-04-22T20:15:14.488Z" }, + { url = "https://files.pythonhosted.org/packages/51/85/8d0c6e5f127da9ebc0fcda6e411592d12b7606347d67aecd4363df5eed6b/pydantic_monty-0.0.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e252ec54fc4728406045f7be36ca45dbea8e6856df9c6154b1b9821b8952dfa2", size = 7769814, upload-time = "2026-04-22T20:14:55.197Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cc/cb4d1b14b039eab00b33a7274f15f81739c3f272e2dfbeb8fb13c6b0c85d/pydantic_monty-0.0.17-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fba71e5cb49f15a1446ecee142c8cc11f4bd6df4fcb4926465c83181474b2fd4", size = 7317432, upload-time = "2026-04-22T20:14:19.993Z" }, + { url = "https://files.pythonhosted.org/packages/c8/16/737c7a023abbcb21848eb4d58f7167d9f4f8cdc46858ce8ed835cc2c137c/pydantic_monty-0.0.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69136647abd56f804987834e37573adcc5c3b3d05013b8b3a2939f44b3bd5199", size = 7767816, upload-time = "2026-04-22T20:13:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9f/5302b784f882ae8a8396f29f8c5ab4c16524c173a3d777b94af33858fdf2/pydantic_monty-0.0.17-cp310-cp310-win32.whl", hash = "sha256:d5b3beb6169b59adea10fdefb1e54bfa9a66165404891dfb6fcf16f7749cda3b", size = 7230648, upload-time = "2026-04-22T20:14:27.03Z" }, + { url = "https://files.pythonhosted.org/packages/1c/27/8c219f619dad466ec25db365acf88e2a50450dd862e0daff0eb281b6176b/pydantic_monty-0.0.17-cp310-cp310-win_amd64.whl", hash = "sha256:50ed9561b6dd1a1863d4cac81e4eaca64cb10ab541aaab92fcb5996739bb8e7f", size = 8075073, upload-time = "2026-04-22T20:14:17.073Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/ca8e42d9f3318f5c454cf8b168d814ec97c6f2afc38756d4b1b806184f6d/pydantic_monty-0.0.17-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:af890d691f6055491a4e643dd5bf09e07bd7a20ad70038531aada6415ab8794a", size = 7344138, upload-time = "2026-04-22T20:13:29.155Z" }, + { url = "https://files.pythonhosted.org/packages/56/c8/cfaf0a56087301d4e88f72cf54ea45a7eebc09c021c85b8864447f1e3755/pydantic_monty-0.0.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f38a69858dfdd2c9474156616d05e25a288e2080aee24152fa40c19ad425f0e", size = 7334903, upload-time = "2026-04-22T20:14:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/51/77/a751a6f73f854aa85fed94cfa5ecab21d7bf218c9fa03c96f9edf470cc4e/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bb88264e291cee56770a775f57125538c4713c6d362e89ee63bff506f650a0df", size = 7864258, upload-time = "2026-04-22T20:13:15.594Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/2eb51eb37e9f712cada64fa8d7df4b63b1f5fc635290147ab158ff0e1ef1/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54c317611454aba8be7ca96aeeea9429f4702a5c4ba89812bea82bed0d8e34fd", size = 7138153, upload-time = "2026-04-22T20:14:22.255Z" }, + { url = "https://files.pythonhosted.org/packages/bb/15/835b10cdec3b96b089eef9899df6850b7f84a10225c491698b0ecf8e532a/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9563b5b4933f0f08c0e66ec66aaa4f43f2388bcc04b984e58aab2146dacd3829", size = 7443572, upload-time = "2026-04-22T20:13:17.951Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/aca140923fad8a2821a135cfeaa2fbb3321063bbadaa760424a016bb1ac6/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35f267a501bc1910178a1515fdd3dd927273fbb44e44b8718cb3b33aee79f41b", size = 7967178, upload-time = "2026-04-22T20:14:06.032Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/7c4ff1e3fe2e82a4745decfca67b54a7a61cd306875e32d8e41c5192c69e/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35b000c52755f25f322ea7c4d079f09aa60635ffe24a6463899e423066a41bf3", size = 8198241, upload-time = "2026-04-22T20:15:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/30/0b/702db7b753b96ebc6713e7cbdfaecdb471df3e3cb0f0f6e828620a743b78/pydantic_monty-0.0.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61b517776ad13aa4580b1dd89188b18296ceeaf88256423563bbc99e804fd83f", size = 7768859, upload-time = "2026-04-22T20:13:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/8d16e0cc0c36d1444f25d57da68dd22216bf0961c457a482429cec32141b/pydantic_monty-0.0.17-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5da5362ef25665a23a3b13024497719f65cafa61d696cac76429f84701bee2e2", size = 7316674, upload-time = "2026-04-22T20:14:52.579Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/d47ae703d402e45475333c4bf11b117c8068305f00c1363dbaea13d0fd09/pydantic_monty-0.0.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7e655b6ddd552c02b751f1d57fc291fbd5654ff8b166a8bd634857879160d0b7", size = 7767515, upload-time = "2026-04-22T20:15:16.539Z" }, + { url = "https://files.pythonhosted.org/packages/99/9b/e17fb50d0df5cf9908f8fffa25c5909ed0eb92ca102ded06f7a6d6133e78/pydantic_monty-0.0.17-cp311-cp311-win32.whl", hash = "sha256:ea8b3ae8c42d572cefad841d3bda63cc458d9de2361cb9172914250e6dbe2c75", size = 7230347, upload-time = "2026-04-22T20:14:08.083Z" }, + { url = "https://files.pythonhosted.org/packages/5e/82/d3119f59652d04bcf69d671ddbd38464d5775fbc738a258d3c8f7800e29d/pydantic_monty-0.0.17-cp311-cp311-win_amd64.whl", hash = "sha256:3293c2f7524bfc7c3d8c794f1c1dc1eb4cf9c65a5e222061e2218ced85f3f6df", size = 8074183, upload-time = "2026-04-22T20:14:50.42Z" }, + { url = "https://files.pythonhosted.org/packages/d1/31/95827babdb35149f076c5d191b6b1e7a7c58f4bc72432f905e02e4e3231e/pydantic_monty-0.0.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27c2254fa7a7b05e969f79578889230d293c62e0b1ee28371ec4f3c54b14426a", size = 7342248, upload-time = "2026-04-22T20:15:18.775Z" }, + { url = "https://files.pythonhosted.org/packages/cb/67/ca9cfc07cd445d22def53e9db86912f9ae3e11ef772ce41c2ff41a47eac5/pydantic_monty-0.0.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:445cc471ce6f5a88ef06741b7ebc7002a2253d182f55a2f47094d4adedaaf497", size = 7311255, upload-time = "2026-04-22T20:15:27.913Z" }, + { url = "https://files.pythonhosted.org/packages/df/96/abc9c4972d91a9673435b84e12b99d038e42d1f99648fc9e5f242e09d00e/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:39121038405911f59da7bf61164251f59bad3fb1b0cd28f43c42c3949eee2c8a", size = 7868109, upload-time = "2026-04-22T20:15:04.779Z" }, + { url = "https://files.pythonhosted.org/packages/75/82/9e4d55529bb99d882b9277a721762537a8bc1345ab1d052bb614a88bd15b/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dafc8ffe57c257002f623afdb7d0e41f73de850179ebd90b42611e4f2b6f9884", size = 7139709, upload-time = "2026-04-22T20:15:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/96/97/f1af6acefb7bb38d73934d6853998bbd327de7418b811372519080d9fd84/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea00838ef8f37dcd8085defcbdfe89fdd05297a6533f1d3f4cad857d13cedc7b", size = 7450444, upload-time = "2026-04-22T20:13:37.974Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/af92ef409e1c065345cf1451bbcf19e00f70a250b8372ec65143ca9a9238/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683d18089acf14d0de293245b9e37c7f0ec64e6d266f6773144211931aa3ec97", size = 7967525, upload-time = "2026-04-22T20:13:42.674Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1f/23ecd6e268ef24ce6b0fe4a1e76a314990d2e923ac5791e29d657418243d/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1829993dd50cf497cbed66ea9f6c8ff7d157d22592a05c7399f92fb8a549e3c", size = 8199124, upload-time = "2026-04-22T20:15:00.02Z" }, + { url = "https://files.pythonhosted.org/packages/42/2a/36b694ea0c7e202250a81a57faf00f218738da6c5d070c752f2d81cd34ce/pydantic_monty-0.0.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a7869e3f41a54cc588096c52a8a4de25ecd81e75c867ea4164b14ea1ae1a57f", size = 7739623, upload-time = "2026-04-22T20:14:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/98/e5/090357d7bc0f0751d1afbb71330695fa26554699c88ba56ecaad91657088/pydantic_monty-0.0.17-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9c17663e2c6f07aec5bc54cd7e39a9e20f250a97a9081e1b2b932eb00d0afc5", size = 7317755, upload-time = "2026-04-22T20:14:48.367Z" }, + { url = "https://files.pythonhosted.org/packages/15/63/67200070cf33325ecfda81d4aee3bf312250ce80bd73058103e04e0f3587/pydantic_monty-0.0.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5d2cf98afe2fb124f6ade91d9663d54277478cad417164f83df1854a41ef450c", size = 7769158, upload-time = "2026-04-22T20:14:39.611Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/9ecfbc2f45406cfb247fafdea4f4a8412db3e559a22c4385eb15266ba2c1/pydantic_monty-0.0.17-cp312-cp312-win32.whl", hash = "sha256:b2185cc4effbbd6793eed4e0f0bcb6a3dbfbb3289ea4d47888708813f0a3dd47", size = 7227917, upload-time = "2026-04-22T20:14:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/d3/80/9be3bef8273817ccc17da25c3ce4ff5d5d45e5629c17eacc90cdef073821/pydantic_monty-0.0.17-cp312-cp312-win_amd64.whl", hash = "sha256:7833daed757ec9b09b627cc3577a4a76b114c5148f779531d7cfdb1095bcf0a9", size = 8043469, upload-time = "2026-04-22T20:13:22.102Z" }, + { url = "https://files.pythonhosted.org/packages/b5/44/0e106b8b27eb93b66e4f3d279486464e05ba5ee31088848e58b5f506f879/pydantic_monty-0.0.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0cdac8c3c16477596bc96ee1cec4f2fbaccd089e2daa1e7b9f227cc89f97cb1", size = 7341507, upload-time = "2026-04-22T20:14:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/e5/88/a0315fa08e62e2d1ef00c03d8202d7bef3f1f71543bebfb916fea265c0a2/pydantic_monty-0.0.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37290d6a1c35aba5cfb8b490bb31c0d822e8ddca8f3ca9ea068e30930d80dd1e", size = 7311916, upload-time = "2026-04-22T20:13:34.022Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e5/e4da6acb408594cbfbfb8dd3c0491b9b2ee54e9183e7ebc5f584baa07af9/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:49f252b2fb918686d3e8f76cb30245e782d1560a7fa68dbc0f6940d83c12bd41", size = 7867465, upload-time = "2026-04-22T20:13:31.466Z" }, + { url = "https://files.pythonhosted.org/packages/58/d4/64c2f8eb708a743b0944ea8f71dfd51bc655285b4be28d55577dafbb29fa/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2254d25c34463d67069f5f1567157bde9311654cd5371f8933b8ea9815bfa26a", size = 7139262, upload-time = "2026-04-22T20:14:10.715Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/5d766f9cd304e871a5dfe5f0a85eaa533538ef07e9b2858fdf9f37f83694/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0688a1fa5dc045ac7b7e996d7269b94f74f116d7b1e352c7b5bb5ad53d4fe03", size = 7450119, upload-time = "2026-04-22T20:13:44.515Z" }, + { url = "https://files.pythonhosted.org/packages/33/98/fa16779021d93edb19807e87cdba56bbec6adfad21f10b41a212572ce513/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b35121ac555ed201405c69d531e4cb916da6984b8cd2e15c8a319117349faf6", size = 7967398, upload-time = "2026-04-22T20:13:55.576Z" }, + { url = "https://files.pythonhosted.org/packages/92/64/287a42720bc9e975ab5b52625aa9fc6bcef8298dd821022cc45c6ee1808d/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c97dc44af25d4392b474902fc40f78f09fe8f44ba791364670334fe12abc077", size = 8198835, upload-time = "2026-04-22T20:15:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/97/37/03edb1fd582b79b2b462afc3fea5e1c8fea73afefc4870dc35fc3c7c492e/pydantic_monty-0.0.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ed2fb365ef9ca921de9a17786ecfa2efe06e65678e6ca57be51658a2a880f31", size = 7739241, upload-time = "2026-04-22T20:13:46.925Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/b765c9ca2ae27def1caa07345aba073ae1239fc2d9cca7a375f3dc2195f7/pydantic_monty-0.0.17-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5bf9f07b38dd12747e3c95b169a5afb3e2e9107622e01e548246c84d19a69c99", size = 7316719, upload-time = "2026-04-22T20:14:13.058Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3b/64fe872cd575ab5262e1ba2959554ead198c939cfaa425f7f8e9b1ad2694/pydantic_monty-0.0.17-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e9bafd4b5acbc0a8e12ee8403a3ce37281c3b0fa5909d3f412bee76c69003c", size = 7769150, upload-time = "2026-04-22T20:13:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/76/b4/b6a0bb41f39bac2e11e6a2fd42ca0886893fafa6344fb17c3f0a94e22e83/pydantic_monty-0.0.17-cp313-cp313-win32.whl", hash = "sha256:1c239ae3e610d3f39cd1609285209a4e2d046b465ac1bfed0d4374c615eed0fa", size = 7227705, upload-time = "2026-04-22T20:15:12.262Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/d8cd62f537f7ab17714ee19ea221a0e341dab407215376ab1c41d79794c9/pydantic_monty-0.0.17-cp313-cp313-win_amd64.whl", hash = "sha256:1886c3590b02f359ae991f1e76691064f167330eda4fbf22762127ce17d0eb48", size = 8043469, upload-time = "2026-04-22T20:14:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/69/c0/8354baf835e1a04c4b9e11d253f82df7d625a9305e6a23a177fb895b1484/pydantic_monty-0.0.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a166bd04d1996f0d144fbb5e1391cd1c0fbdacd4fa3b689dd48931388679fe98", size = 7341303, upload-time = "2026-04-22T20:14:35.653Z" }, + { url = "https://files.pythonhosted.org/packages/00/ac/d58221b5e17915421ca00bb08b805ac121b6accb194785d5422da4a2f5fc/pydantic_monty-0.0.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d82f319e3fd79707a7b81bbb68596509d14ac73502d2f0daf4ab5d281efdfbdc", size = 7318912, upload-time = "2026-04-22T20:13:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/81/3f/8eeb8f652f6cd6e06a737aa9f00de2949e37a669b31756bc51b6182457b4/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f38b9875f7ff56fe69538b60b2ecbdcbe2b8b7780407ceb05fe0ee1414bf8d19", size = 7867027, upload-time = "2026-04-22T20:13:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/ec6620c27c8b4cada6ce53378c52c245e238e50a20b968cafdc1b8573c4e/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74a778bb5a4dcdbc85b3b9002f9a72a43fb6ffd88635bfdd502e8d3053008337", size = 7137542, upload-time = "2026-04-22T20:13:35.939Z" }, + { url = "https://files.pythonhosted.org/packages/41/78/5419785630511b54b15cfb094871bcb53ec9025ba8d91bd7ab5b22b6c98f/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e2ab074a65738e9c1b4be9a432e7ca1e9987a6706018dc7a5af6d4ce7cecdf3", size = 7450222, upload-time = "2026-04-22T20:13:53.378Z" }, + { url = "https://files.pythonhosted.org/packages/61/04/cec11fa96a47034da3c21af53e73f43d2270f5ce96cd710809859fe9c0b2/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00fd1cd28b4200c9ccd02629868486b366af1bfe1f0584d4c9e513b7a941a868", size = 7967405, upload-time = "2026-04-22T20:14:03.826Z" }, + { url = "https://files.pythonhosted.org/packages/81/e3/f2be0fb975100b6936ca36a8410098f10fab3b26729c0b0d1de2fac59ff3/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ea6684555bfd00cbe9d2df3e73caada3d82200c168c63cc475197b55b88401", size = 8199028, upload-time = "2026-04-22T20:13:26.802Z" }, + { url = "https://files.pythonhosted.org/packages/2c/43/358bdaa9c50d21fe4a25a71d43ce9af2d6796616fa47aca84f807433564e/pydantic_monty-0.0.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f804a03a3bbd0cf0ade1d4ce11b50ca6858e9c4440b27c746faf1d3c0a272954", size = 7749903, upload-time = "2026-04-22T20:15:09.626Z" }, + { url = "https://files.pythonhosted.org/packages/9a/da/8bcd0a78abf13edceca36aaf5c180fad963e4c2e042fd7247b9e96048306/pydantic_monty-0.0.17-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:b5476e6c08b86b0bea554b97ce9b142aac1177447f2d3c5751864b27991fd1b1", size = 7312704, upload-time = "2026-04-22T20:14:33.668Z" }, + { url = "https://files.pythonhosted.org/packages/88/49/5de8bb7f8b82c3ebb8f2485e0b7a40055b072193039d95dcb5d35fcba72c/pydantic_monty-0.0.17-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b5fcdcca45439844bee268686f37226dbf5803ec7a5945f5536c41419f151dac", size = 7768902, upload-time = "2026-04-22T20:14:29.389Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7c/500a002f1a52f17c8b8a989875da3e1590c18ce95c89856aa967e2348a36/pydantic_monty-0.0.17-cp314-cp314-win32.whl", hash = "sha256:4dd3e6e80a415e7272f7a7583a4f8e045096653f6074e181117eb61fc8fe3b45", size = 7227049, upload-time = "2026-04-22T20:14:01.871Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/b8f552f2a863778f49ebb708f18aaf0bfb275480a48965786e06efacf1c4/pydantic_monty-0.0.17-cp314-cp314-win_amd64.whl", hash = "sha256:36a8090a628e8cf91df8f66c721a71050ac8f48473d4992b9afbd9585941a647", size = 8062999, upload-time = "2026-04-22T20:14:44.006Z" }, ] [[package]] @@ -2367,7 +2370,7 @@ wheels = [ [[package]] name = "pydocket" -version = "0.20.1" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "burner-redis" }, @@ -2386,9 +2389,9 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "uncalled-for" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/bf/7f1134e990855f373e5ee6ba316db8fe654a2d7dd852b41ab890fcfb91e3/pydocket-0.20.1.tar.gz", hash = "sha256:d72b3784e4b5069b39e5f49f599d54a891e1b6222c27a8bcfbd4dee0f57d4895", size = 361993, upload-time = "2026-05-06T14:06:25.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/9d/05d54dccfaa505c0bc2e480bc331c44552cdc16af3f44f5893c75293a165/pydocket-0.20.0.tar.gz", hash = "sha256:4b5132a5754ba54f894d46bf2cbdc12e237adada73bc76ca367017536098df7f", size = 361050, upload-time = "2026-05-04T00:27:34.393Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/9d/1bd873a0ea480dec388c40ac1a7500c129efbb9d61e2fef6b97236703458/pydocket-0.20.1-py3-none-any.whl", hash = "sha256:c886ece90ac93018f069d1eef9443f888404081d7258955e16847752575c95ae", size = 102774, upload-time = "2026-05-06T14:06:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/17/cb/635665c07be980ec48c92b830907e6796012801b107cb1166a213e49ec38/pydocket-0.20.0-py3-none-any.whl", hash = "sha256:1f745278be09d3526f1bdd579c2d92f77fa0a534a39b893e9ef21dfc2ee52378", size = 102483, upload-time = "2026-05-04T00:27:32.799Z" }, ] [[package]] @@ -2484,11 +2487,11 @@ wheels = [ [[package]] name = "pyreadline3" -version = "3.5.6" +version = "3.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] [[package]] @@ -2502,7 +2505,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.1.1" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2513,23 +2516,23 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.4.0" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] @@ -2548,16 +2551,16 @@ wheels = [ [[package]] name = "pytest-env" -version = "1.7.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "python-dotenv" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/49/08ee056f9cc655e437abcf2ae399884844b623223476ae6a77244131db03/pytest_env-1.7.0.tar.gz", hash = "sha256:0c1dc1101fb8d3ab3611e8f8d657ba06c3c0c167fc85c90457e5b27f2508f43e", size = 16408, upload-time = "2026-07-21T13:09:21.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163, upload-time = "2026-03-12T22:39:43.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/fc/9f2975c41d41bf5bd9a7d0fc03085ec20052b456b079df53828ae4a1b100/pytest_env-1.7.0-py3-none-any.whl", hash = "sha256:9ee0f1fe859d23fcdb533fe2909a404b3b133d02674a56df275bbe4df4eb104b", size = 10263, upload-time = "2026-07-21T13:09:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" }, ] [[package]] @@ -2700,27 +2703,24 @@ wheels = [ [[package]] name = "pywin32" -version = "312" +version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, - { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, - { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, - { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, - { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, - { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, - { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, - { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, - { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, + { 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]] @@ -2798,14 +2798,14 @@ wheels = [ [[package]] name = "redis" -version = "8.0.1" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, ] [[package]] @@ -2814,8 +2814,7 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -2825,7 +2824,7 @@ wheels = [ [[package]] name = "requests" -version = "2.34.2" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2833,44 +2832,41 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] name = "rich" -version = "15.0.0" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "rich-rst" -version = "2.1.0" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "docutils" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, ] [[package]] name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, @@ -2989,157 +2985,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] -[[package]] -name = "rpds-py" -version = "2026.6.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, - { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, - { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, - { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, - { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, - { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, - { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, - { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, - { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, - { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, - { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, - { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, - { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, - { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, - { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, - { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, - { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, - { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, - { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, - { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, - { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, - { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, - { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, - { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, - { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, - { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, - { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, - { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, - { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, - { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, - { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, - { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, - { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, - { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, - { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, - { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, - { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, - { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, - { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, - { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, - { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, - { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, - { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, - { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, - { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, - { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, -] - [[package]] name = "ruff" -version = "0.15.22" +version = "0.15.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -3175,15 +3043,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.4.6" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, ] [[package]] @@ -3291,23 +3159,23 @@ wheels = [ [[package]] name = "tqdm" -version = "4.69.0" +version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] name = "traitlets" -version = "5.15.1" +version = "5.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] [[package]] @@ -3321,51 +3189,51 @@ wheels = [ [[package]] name = "ty" -version = "0.0.61" +version = "0.0.55" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/63/6944925d0fe9a4bb9cc744e6c045a42bbd2ee4654c103190674577a36c3f/ty-0.0.61.tar.gz", hash = "sha256:acbf0d914cc7e2e57ccc440036af36114819e2a604a5ffb554e72e4ca7dd65a2", size = 6234957, upload-time = "2026-07-18T01:39:54.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/cf/044f31523e2768e3e64b0ca2ec32f70b3a731d4a2caa6ea110baf26e251c/ty-0.0.61-py3-none-linux_armv6l.whl", hash = "sha256:148779b8675eac93f40ec58bd70037fe67537117f20a23272264f8f136d41336", size = 11891448, upload-time = "2026-07-18T01:39:18.449Z" }, - { url = "https://files.pythonhosted.org/packages/d2/55/558cfe76b65d91d1854bbfac336020bd42fd887caa632d845d13c0c539eb/ty-0.0.61-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:08217382b3385808ee7288501ea3214b32631b08d1fd091ece6799b0c95264c5", size = 11602442, upload-time = "2026-07-18T01:39:20.914Z" }, - { url = "https://files.pythonhosted.org/packages/27/be/78c0ae6634cd606a68e5b46b338db427a48a1800c96a749b2d2f7a702e03/ty-0.0.61-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d99c729011b47dec20e78a32ac9c8f6defd4cf62f7bb851bbccf70dde6cee50", size = 11125286, upload-time = "2026-07-18T01:39:22.893Z" }, - { url = "https://files.pythonhosted.org/packages/a4/18/a40793962f1b6337938ddb0bca7496b54e70879e23b4d2cc8dfd7e5d1af3/ty-0.0.61-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cda607978ae271b77e51c947663218bce635c3507e256865444b10c37cdb60d", size = 11663403, upload-time = "2026-07-18T01:39:25.017Z" }, - { url = "https://files.pythonhosted.org/packages/98/c1/7879244da5b30407dc368946d36be5024380073408b079f144ffe034030e/ty-0.0.61-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d78f160a0f9434d570cdcdbc4dafba1f6aac3c47a32f9f63995b3cb55ffe4b6", size = 11715250, upload-time = "2026-07-18T01:39:27.045Z" }, - { url = "https://files.pythonhosted.org/packages/35/c4/8a4637cd58abd37f315dd515e24c582986cb1bfdf2edc4786882f5a4f69a/ty-0.0.61-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09aeab4800b36e93e4ce918699004da642d74988cac920b7592a6a2b9be6611c", size = 12393876, upload-time = "2026-07-18T01:39:29.197Z" }, - { url = "https://files.pythonhosted.org/packages/27/4b/27e7c640b1272743503229aa17ae2167a538040c4716a2fa1777c2b34fea/ty-0.0.61-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dccc8136df44142a109953a168be17b4915c99876b047d0b6672c31dae939bdf", size = 12958187, upload-time = "2026-07-18T01:39:31.308Z" }, - { url = "https://files.pythonhosted.org/packages/3a/f5/70eaaefb6081fb0a8115cff66fbfaa20dafac8c646df2477adad95a59de2/ty-0.0.61-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:220760c2d13a887d027ee1093172c24ac35b6e634805329c93a30908ae4d3f5c", size = 12560101, upload-time = "2026-07-18T01:39:33.35Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/17bae3b6429b5c479dc6c1e344d34e1f79efbc27531f15f3ee5b5da63745/ty-0.0.61-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:effefbb89da7128d18059529d1c2ea390fe7f1f3882690d257ca2143d49a0c34", size = 12225389, upload-time = "2026-07-18T01:39:35.436Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/2ac380ba20d6395542c8df1d6fa4f00e2aead784c2e6aaefa1e02ed0610c/ty-0.0.61-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba8b28a5ef811d5bb6461e37d76110c06fd20487474865c323d3d18b08b972b2", size = 12548403, upload-time = "2026-07-18T01:39:37.556Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e5/7da4b73e825e1a9808c26d68b0156e9a37aede1846191210dfffb8c64042/ty-0.0.61-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88ecd6d9b05e8174b1860dac9bd3e188d6cef5702b0d3239fd9f94f6ac73a29d", size = 11621813, upload-time = "2026-07-18T01:39:39.919Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/5b58015e998cd0d89b17a463b6321421457d86d987574e8dac65ddfceba3/ty-0.0.61-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb0cdfe4c48542ffb9a1139825dfa3d4aae49e96e966682ef7da762ab97831ff", size = 11734101, upload-time = "2026-07-18T01:39:42.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/21/294f4cc819b7b12ed659fd860e5cdfbd592d4c768c8f23596685dbc43e6b/ty-0.0.61-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dff03873c0c3d0b44738f8b6d403b0756a31cf54c65136397df7624c6159b1f0", size = 11988401, upload-time = "2026-07-18T01:39:44.183Z" }, - { url = "https://files.pythonhosted.org/packages/2e/26/0f96f79fdac118521a9771e9eef3f9b3f447d647b2c77953e80a1715c7e8/ty-0.0.61-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a9210e80e3d41c1dfc751e9e8e0980272f475031fafd0fb0f48aee233c78da03", size = 12330624, upload-time = "2026-07-18T01:39:46.662Z" }, - { url = "https://files.pythonhosted.org/packages/e3/08/1e62d1bca5c0cebdc7a34db1f4b61557aab85961cedd56953dd2c32d3e66/ty-0.0.61-py3-none-win32.whl", hash = "sha256:e3e1fe06f49a5492a922a5df2739834aa5ee978c7dd10414119dc8755cc40c9c", size = 11313991, upload-time = "2026-07-18T01:39:48.761Z" }, - { url = "https://files.pythonhosted.org/packages/26/f1/d8e33b3aeb36b73d81ae34d10e46ec4abf506d68f4e0a1491a76a593dd42/ty-0.0.61-py3-none-win_amd64.whl", hash = "sha256:25f2291169e0298fcdbba1b1fea64f8207a6c1908dddef32346fd5e3e6ac9221", size = 12311717, upload-time = "2026-07-18T01:39:50.881Z" }, - { url = "https://files.pythonhosted.org/packages/e1/14/7caec26d93a943c0e7d15eb7374644508d08cbd387d112b722b12d14e044/ty-0.0.61-py3-none-win_arm64.whl", hash = "sha256:3e496f7698bc4b5bbb1eb66d8b5799ba87596d88d36604ca359083893fa2fc49", size = 11693485, upload-time = "2026-07-18T01:39:52.73Z" }, + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, ] [[package]] name = "typer" -version = "0.27.0" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "click" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] [[package]] name = "typing-extensions" -version = "4.16.0" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +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/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, + { 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]] @@ -3382,20 +3250,20 @@ wheels = [ [[package]] name = "tzdata" -version = "2026.3" +version = "2025.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] [[package]] name = "uncalled-for" -version = "0.3.2" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, ] [[package]] @@ -3409,265 +3277,203 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.51.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [[package]] name = "watchfiles" -version = "1.2.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, - { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, - { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, - { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, - { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, - { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, - { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, - { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, - { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, - { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, - { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, - { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, - { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, - { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, - { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, - { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, - { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, - { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, - { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, - { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, - { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, - { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, - { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, - { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, - { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, - { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, - { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, - { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, - { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, - { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, - { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, - { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, - { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, - { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, - { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, - { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, - { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, - { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] [[package]] name = "wcwidth" -version = "0.8.2" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] [[package]] name = "websockets" -version = "16.1.1" +version = "16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" }, - { url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" }, - { url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" }, - { url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" }, - { url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" }, - { url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" }, - { url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" }, - { url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" }, - { url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" }, - { url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" }, - { url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, - { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, - { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, - { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, - { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, - { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, - { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, - { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, - { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, - { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, - { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, - { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, - { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, - { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, - { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, - { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, - { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, - { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, - { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, - { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, - { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, - { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, - { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, - { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, - { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, - { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, - { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, - { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, - { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, - { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, - { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, - { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, - { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, - { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, - { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, - { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, - { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, - { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, - { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, - { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, - { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "zipp" -version = "4.1.0" +version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] diff --git a/dev-docs/v3-notes/get-methods-consolidation.md b/v3-notes/get-methods-consolidation.md similarity index 100% rename from dev-docs/v3-notes/get-methods-consolidation.md rename to v3-notes/get-methods-consolidation.md diff --git a/dev-docs/v3-notes/prompt-internal-types.md b/v3-notes/prompt-internal-types.md similarity index 100% rename from dev-docs/v3-notes/prompt-internal-types.md rename to v3-notes/prompt-internal-types.md diff --git a/dev-docs/v3-notes/provider-architecture.md b/v3-notes/provider-architecture.md similarity index 100% rename from dev-docs/v3-notes/provider-architecture.md rename to v3-notes/provider-architecture.md diff --git a/dev-docs/v3-notes/provider-test-pattern.md b/v3-notes/provider-test-pattern.md similarity index 100% rename from dev-docs/v3-notes/provider-test-pattern.md rename to v3-notes/provider-test-pattern.md diff --git a/dev-docs/v3-notes/resource-internal-types.md b/v3-notes/resource-internal-types.md similarity index 100% rename from dev-docs/v3-notes/resource-internal-types.md rename to v3-notes/resource-internal-types.md diff --git a/dev-docs/v3-notes/task-meta-parameter.md b/v3-notes/task-meta-parameter.md similarity index 100% rename from dev-docs/v3-notes/task-meta-parameter.md rename to v3-notes/task-meta-parameter.md diff --git a/dev-docs/v3-notes/visibility.md b/v3-notes/visibility.md similarity index 100% rename from dev-docs/v3-notes/visibility.md rename to v3-notes/visibility.md